POSIX to BCP 47 Locale Conversion Pitfalls
Why en_US.UTF-8 is not the same as en-US, how Accept-Language quality weights work, and how to convert locale strings for HTML, JavaScript Intl, and shells.
A support ticket says the invoice date is "wrong." The amount is right. The timezone is UTC. The screenshot still shows 10.08.2026 for a customer who expected 8/10/2026. That is a locale bug, not a clock bug. The backend emitted en_US.UTF-8. The frontend passed it straight into Intl.DateTimeFormat. Browsers want a BCP 47 tag like en-US. The extra charset suffix, or an underscore, is enough to make formatting fall back to a parent locale - or throw in older runtimes.
This guide is for the moment you have a POSIX LANG value, an Accept-Language header, or an HTML lang attribute and you need the other forms. It covers the mismatches that show up in Docker images, Next.js apps, and production logs, and how to check them with Utilitoo's Locale Converter before you change i18n config.
What the strings actually mean
People say "locale" when they mean five different identifiers:
| Form | Example | Where it belongs |
|---|---|---|
| BCP 47 | en-US, zh-Hans-CN | HTML lang, JavaScript Intl, most web APIs |
| POSIX / libc | en_US.UTF-8 | Shells, LANG / LC_ALL, older JVM and C libraries |
| HTML lang | en-US (same as BCP 47) | <html lang="..."> for accessibility and translation hints |
| Accept-Language | fr-CA,fr;q=0.9,en;q=0.8 | HTTP request preference chain, not a single locale |
| IANA timezone | America/New_York | Wall-clock offset and DST - not a language tag |
If your app stores en_US.UTF-8 in a user profile and later feeds it to Intl.NumberFormat, you are mixing two contracts. Convert at the boundary: POSIX in, BCP 47 out for the browser.
Pitfall 1: Underscores and charset suffixes
POSIX locales look like language_REGION.charset and sometimes @modifier:
en_US.UTF-8pt_BR.ISO-8859-1sr_RS@latin
BCP 47 uses hyphens, no charset, and script subtags instead of @latin: en-US, pt-BR, sr-Latn-RS.
Typical scene: A Node process inherits LANG=en_US.UTF-8 from the container. new Intl.NumberFormat(process.env.LANG) either throws or silently falls back. Numbers look "European" in a US product, or dates swap day and month.
What to do: Strip the charset and modifier, swap underscores for hyphens, then canonicalize. Paste en_US.UTF-8 into Locale Converter and click Convert. You should get BCP 47 en-US, a POSIX-style en_US.UTF-8 again, and HTML lang matching the BCP 47 tag. The charset disappearing on the BCP 47 side is expected - Unicode is assumed on the web.
Pitfall 2: Accept-Language is a list, not a locale
HTTP clients send a preference chain with quality weights:
Accept-Language: fr-CA,fr;q=0.9,en;q=0.8
That is not "the user's locale is fr-CA,fr;q=0.9,en;q=0.8." It is "prefer Canadian French, then any French, then English." If you stuff the raw header into html lang or Intl, you will get parse errors or a useless fallback.
Typical scene: A CDN log line includes the full header. Support pastes it into a language picker. The picker stores the entire string. Later, toLocaleString fails in Safari only.
What to do: Parse the header, take the primary tag (fr-CA), convert that, then map it onto locales your product actually ships. A user who sends fr-CA might still need fr content if you have no Canadian-French pack. Locale Converter extracts the chain and converts the primary tag. Pair with HTTP Header Viewer when you have a live URL, or User Agent Parser when the ticket also includes a User-Agent - language is not in the UA string.
Pitfall 3: Script subtags change the writing system
Chinese and Serbian are the usual surprises:
zh_CNvszh_Hans_CNvszh_TWvszh_Hant_TWsr_RSvssr_Latn_RS
zh-CN often implies Simplified Chinese in browsers; zh-TW implies Traditional. Passing zh alone can pick the wrong script. POSIX zh_Hans_CN should become BCP 47 zh-Hans-CN, not zh-CN with the script dropped unless you intend that collapse.
Typical scene: A CMS stores zh_CN. Marketing pastes Traditional Chinese copy. The page lang is zh-CN. Screen readers and translation tools disagree with the glyphs on the page.
What to do: Convert the identifier you actually have, then read language, script, and region as separate fields. Locale Converter maximizes the tag (adds likely script/region via Intl.Locale) so you can see whether the browser thinks this is Hans or Hant before you ship.
Pitfall 4: Locale is not timezone
en-GB formats dates as day-month-year and uses a comma or no thousands separator depending on the API. Europe/London is the civil-time zone, including BST. You can have en-US copy for a user sitting in Asia/Tokyo. Mixing them produces two different "the date is wrong" tickets:
- Wrong offset - meeting at 09:00 instead of 10:00. Use Timezone Converter.
- Wrong pattern -
3/4/2026vs4/3/2026, or1,234.56vs1.234,56. Use Locale Converter.
Epoch values have no locale. 1718745600 is an instant. Locale only appears when you display that instant. Convert the timestamp first with Timestamp Converter, then preview the same instant with a BCP 47 tag if the complaint is about separators and order.
Pitfall 5: The resolved locale is not what you typed
Browsers canonicalize and fall back. You type en-US-u-nu-latn or a rare regional tag; Intl.NumberFormat reports a parent like en-US or even en. That is not a converter bug. It is the implementation telling you which locale data it actually loaded.
Typical scene: QA expects de-AT currency to show โฌ with Austrian grouping. The preview shows de or de-DE because the engine folded the region. Shipping copy still needs a real de-AT resource pack if wording differs, even when number patterns match Germany.
What to do: After Convert, read Resolved number locale and Resolved date locale on Locale Converter. If they differ from the input, decide whether fallback is acceptable or whether you must ship explicit locale data.
A conversion checklist
- Identify the source form - POSIX env, BCP 47, HTML lang, or Accept-Language header
- Do not pass charset suffixes into
Intlorlang - Convert the primary Accept-Language tag, then match it to locales you support
- Keep script subtags for Chinese, Serbian, and similar languages
- Separate timezone from locale - offset vs number/date pattern
- Check resolved Intl locales in the browser you actually ship to, not only in Node
How Utilitoo tools fit
- Locale Converter - paste
en_US.UTF-8,zh_Hans_CN,en-US, or a fullAccept-Languageheader; copy BCP 47, POSIX, HTML lang, and live number/date/currency previews - HTTP Header Viewer - fetch a public URL when you need the real Accept-Language on a response path, then convert the tag
- User Agent Parser - browser and OS from a UA; language still comes from Accept-Language
- Timezone Converter - when the bug is DST or city offset, not
en-GBvsen-USdate order - Timestamp Converter - when the value is an epoch instant and locale only affects how you print it
Everything in Locale Converter runs in your browser via Intl. Do not paste live session cookies if you copied a larger header block by mistake.
Common mistakes
| Mistake | What goes wrong | Better habit |
|---|---|---|
Intl.NumberFormat("en_US.UTF-8") | Throw or silent fallback | Convert to en-US first |
| Storing the raw Accept-Language header | Invalid lang, broken formatters | Store the primary tag, keep the chain for negotiation |
Treating zh as enough | Wrong Chinese script | Keep Hans / Hant when you have it |
"Fixing" 10.08.2026 with a timezone offset | Date order still wrong | Convert the locale, not the zone |
| Trusting input over resolved locale | QA sees a tag the engine did not use | Read resolved number/date locale |
Bottom line
Locale conversion is a boundary problem. Shells speak POSIX. The web speaks BCP 47. HTTP speaks a weighted list. Convert once at the edge, keep timezone as a separate field, and preview numbers and dates in the same browser engine your users have.
Utilitoo's Locale Converter is the check for that: paste the messy string, copy the canonical tag, and confirm the Intl preview before you ship the lang attribute or the NumberFormat call.
Try these tools
- Locale Converter - Convert BCP 47, POSIX, and Accept-Language locale strings.
- Timezone Converter - Convert times between timezones.
- Timestamp Converter - Convert Unix timestamps to dates and back.
- HTTP Header Viewer - View HTTP response headers for any URL.
- User Agent Parser - Parse browser user agent strings.
