โ† Back to blog

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:

FormExampleWhere it belongs
BCP 47en-US, zh-Hans-CNHTML lang, JavaScript Intl, most web APIs
POSIX / libcen_US.UTF-8Shells, LANG / LC_ALL, older JVM and C libraries
HTML langen-US (same as BCP 47)<html lang="..."> for accessibility and translation hints
Accept-Languagefr-CA,fr;q=0.9,en;q=0.8HTTP request preference chain, not a single locale
IANA timezoneAmerica/New_YorkWall-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-8
  • pt_BR.ISO-8859-1
  • sr_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_CN vs zh_Hans_CN vs zh_TW vs zh_Hant_TW
  • sr_RS vs sr_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/2026 vs 4/3/2026, or 1,234.56 vs 1.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

  1. Identify the source form - POSIX env, BCP 47, HTML lang, or Accept-Language header
  2. Do not pass charset suffixes into Intl or lang
  3. Convert the primary Accept-Language tag, then match it to locales you support
  4. Keep script subtags for Chinese, Serbian, and similar languages
  5. Separate timezone from locale - offset vs number/date pattern
  6. 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 full Accept-Language header; 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-GB vs en-US date 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

MistakeWhat goes wrongBetter habit
Intl.NumberFormat("en_US.UTF-8")Throw or silent fallbackConvert to en-US first
Storing the raw Accept-Language headerInvalid lang, broken formattersStore the primary tag, keep the chain for negotiation
Treating zh as enoughWrong Chinese scriptKeep Hans / Hant when you have it
"Fixing" 10.08.2026 with a timezone offsetDate order still wrongConvert the locale, not the zone
Trusting input over resolved localeQA sees a tag the engine did not useRead 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