# Locale Codes Explained: BCP 47, en-US vs en_US, zh-Hans vs zh-CN

> A practical reference to BCP 47 language tags: how locale codes are built, the language-versus-country mistakes that silently break apps, platform conventions, Unicode extensions, and how to negotiate a user's language correctly.
- **Author**: Mohit Sapat
- **Published**: 2026-10-05
- **URL**: https://www.babelize.co/blogs/locale-codes-bcp-47-explained

---

<p class="lead">
    <code>en-US</code>, <code>en_US</code>, <code>zh-Hans</code>, <code>zh-CN</code>, <code>es-419</code>: locale codes look simple until you have to pick one for a translation file, a URL, an hreflang tag, and an <code>Intl</code> call, and they all disagree. This is a practical reference to BCP 47 language tags: how they are built, which codes developers most often get wrong, and how to match a user's language to the locales you actually support.
</p>

<h2>What a Locale Code Actually Is</h2>

<p>
    The web standard for identifying a language is the <strong>BCP 47 language tag</strong>, defined in <a href="https://www.rfc-editor.org/info/bcp47" target="_blank" rel="noopener noreferrer">IETF BCP 47</a> (RFC 5646 and RFC 4647). It is what the <code>lang</code> attribute, the <code>Accept-Language</code> header, hreflang, and every JavaScript <code>Intl</code> API expect.
</p>

<p>
    A tag is built from hyphen-separated subtags, and only the first is required:
</p>

<table>
    <thead>
        <tr>
            <th>Tag</th>
            <th>Language</th>
            <th>Script</th>
            <th>Region</th>
            <th>Extension</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>zh-Hant-TW</code></td>
            <td><code>zh</code></td>
            <td><code>Hant</code></td>
            <td><code>TW</code></td>
            <td>--</td>
        </tr>
        <tr>
            <td><code>sr-Latn-RS</code></td>
            <td><code>sr</code></td>
            <td><code>Latn</code></td>
            <td><code>RS</code></td>
            <td>--</td>
        </tr>
        <tr>
            <td><code>es-419</code></td>
            <td><code>es</code></td>
            <td>--</td>
            <td><code>419</code></td>
            <td>--</td>
        </tr>
        <tr>
            <td><code>en-US-u-hc-h23</code></td>
            <td><code>en</code></td>
            <td>--</td>
            <td><code>US</code></td>
            <td><code>u-hc-h23</code></td>
        </tr>
    </tbody>
</table>

<table>
    <thead>
        <tr>
            <th>Subtag</th>
            <th>Standard</th>
            <th>Format</th>
            <th>Examples</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Language</td>
            <td>ISO 639</td>
            <td>2 or 3 lowercase letters</td>
            <td><code>en</code>, <code>de</code>, <code>ja</code>, <code>fil</code></td>
        </tr>
        <tr>
            <td>Script</td>
            <td>ISO 15924</td>
            <td>4 letters, Titlecase</td>
            <td><code>Latn</code>, <code>Cyrl</code>, <code>Hans</code>, <code>Hant</code></td>
        </tr>
        <tr>
            <td>Region</td>
            <td>ISO 3166-1 or UN M.49</td>
            <td>2 uppercase letters or 3 digits</td>
            <td><code>US</code>, <code>BR</code>, <code>419</code></td>
        </tr>
        <tr>
            <td>Extension</td>
            <td>Unicode (<code>-u-</code>)</td>
            <td>Key-value pairs</td>
            <td><code>-u-nu-latn</code>, <code>-u-ca-buddhist</code></td>
        </tr>
    </tbody>
</table>

<p>
    Tags are case-insensitive, so <code>EN-us</code> is technically valid. The case convention above exists for readability, and <code>Intl.getCanonicalLocales()</code> will normalize anything you receive:
</p>

<pre><code>Intl.getCanonicalLocales(["EN-us", "zh-hant-tw", "iw"]);
// ["en-US", "zh-Hant-TW", "he"]   (iw is the deprecated code for Hebrew)</code></pre>

<h2>Language, Script, Region: When to Use Each</h2>

<h3>Use the shortest tag that is correct</h3>

<p>
    Start with just the language: <code>de</code>, <code>fr</code>, <code>ja</code>. Add a region only when your content genuinely differs between regions. Splitting <code>de-DE</code>, <code>de-AT</code>, and <code>de-CH</code> means three translation files to maintain and, on a public site, three near-identical pages competing in search. Do it when prices, legal text, or vocabulary actually differ.
</p>

<p>
    Languages where the region usually matters:
</p>

<ul>
    <li><strong>Portuguese</strong>: <code>pt-BR</code> and <code>pt-PT</code> differ in vocabulary, spelling, and formality. Most SaaS products start with <code>pt-BR</code> because Brazil is the larger market.</li>
    <li><strong>Spanish</strong>: <code>es-ES</code> versus Latin American Spanish, often tagged <code>es-419</code> (419 is the UN M.49 code for Latin America).</li>
    <li><strong>English</strong>: <code>en-US</code> versus <code>en-GB</code> mostly affects spelling ("color" versus "colour") and date order.</li>
    <li><strong>French</strong>: <code>fr-CA</code> differs from <code>fr-FR</code> in vocabulary and some formatting conventions.</li>
</ul>

<h3>Chinese needs a script, not just a region</h3>

<p>
    The real difference in written Chinese is Simplified versus Traditional characters, which is a script distinction. <code>zh-CN</code> and <code>zh-TW</code> work as shorthand, but they encode the wrong thing: Singapore uses Simplified, Hong Kong uses Traditional, and a Traditional-script reader in the US is neither.
</p>

<pre><code>new Intl.Locale("zh-TW").maximize().toString(); // "zh-Hant-TW"
new Intl.Locale("zh-CN").maximize().toString(); // "zh-Hans-CN"
new Intl.Locale("zh-SG").maximize().toString(); // "zh-Hans-SG"</code></pre>

<p>
    Name your Chinese translation files <code>zh-Hans</code> and <code>zh-Hant</code>, and map regional tags to them during negotiation. Serbian (<code>sr-Cyrl</code> and <code>sr-Latn</code>) has the same issue.
</p>

<h3>Inspecting tags with Intl.Locale</h3>

<pre><code>const loc = new Intl.Locale("sr-Latn-RS-u-nu-latn");
loc.language;        // "sr"
loc.script;          // "Latn"
loc.region;          // "RS"
loc.numberingSystem; // "latn"
loc.baseName;        // "sr-Latn-RS"

// maximize() fills in likely subtags, minimize() strips redundant ones
new Intl.Locale("en").maximize().toString();         // "en-Latn-US"
new Intl.Locale("zh-Hant-TW").minimize().toString(); // "zh-TW"</code></pre>

<h2>The Codes Developers Get Wrong Most</h2>

<p>
    Country codes and language codes are different lists, and they often look alike. Several mistakes are silent: the tag is valid, it just means a different language.
</p>

<table>
    <thead>
        <tr>
            <th>Intended</th>
            <th>Wrong</th>
            <th>Correct</th>
            <th>Why</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>British English</td>
            <td><code>en-UK</code></td>
            <td><code>en-GB</code></td>
            <td>The ISO country code for the United Kingdom is GB</td>
        </tr>
        <tr>
            <td>Japanese</td>
            <td><code>jp</code></td>
            <td><code>ja</code></td>
            <td>JP is the country; ja is the language</td>
        </tr>
        <tr>
            <td>Chinese</td>
            <td><code>cn</code></td>
            <td><code>zh</code></td>
            <td>CN is the country</td>
        </tr>
        <tr>
            <td>Korean</td>
            <td><code>kr</code></td>
            <td><code>ko</code></td>
            <td>KR is the country</td>
        </tr>
        <tr>
            <td>Swedish</td>
            <td><code>se</code></td>
            <td><code>sv</code></td>
            <td><code>se</code> is Northern Sami</td>
        </tr>
        <tr>
            <td>Ukrainian</td>
            <td><code>ua</code></td>
            <td><code>uk</code></td>
            <td>UA is the country; <code>uk</code> is the language</td>
        </tr>
        <tr>
            <td>Danish</td>
            <td><code>dk</code></td>
            <td><code>da</code></td>
            <td>DK is the country</td>
        </tr>
        <tr>
            <td>Czech</td>
            <td><code>cz</code></td>
            <td><code>cs</code></td>
            <td>CZ is the country</td>
        </tr>
        <tr>
            <td>Greek</td>
            <td><code>gr</code></td>
            <td><code>el</code></td>
            <td>GR is the country</td>
        </tr>
        <tr>
            <td>Hebrew</td>
            <td><code>iw</code></td>
            <td><code>he</code></td>
            <td><code>iw</code> is deprecated but still returned by some older Java and Android APIs</td>
        </tr>
        <tr>
            <td>Norwegian</td>
            <td><code>no</code></td>
            <td><code>nb</code> (or <code>nn</code>)</td>
            <td>Bokmål and Nynorsk are distinct written standards</td>
        </tr>
    </tbody>
</table>

<p>
    The <code>se</code> versus <code>sv</code> mistake is the nastiest because nothing errors. Your Swedish file loads, your <code>lang</code> attribute says Northern Sami, and screen readers and search engines believe it.
</p>

<h2>Underscores, Hyphens, and Platform Conventions</h2>

<p>
    <code>en_US</code> with an underscore is not a BCP 47 tag. It is the POSIX and older ICU/Java locale format, and you will see it in gettext <code>.po</code> files, Linux <code>LANG</code> variables, and many legacy systems. Browsers and the <code>Intl</code> API reject it:
</p>

<pre><code>new Intl.Locale("en_US");
// RangeError: Incorrect locale information provided</code></pre>

<table>
    <thead>
        <tr>
            <th>Platform</th>
            <th>Format</th>
            <th>Example</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Web, HTML <code>lang</code>, <code>Intl</code>, hreflang</td>
            <td>BCP 47</td>
            <td><code>pt-BR</code></td>
        </tr>
        <tr>
            <td>POSIX, gettext, Linux</td>
            <td>Underscore</td>
            <td><code>pt_BR</code></td>
        </tr>
        <tr>
            <td>Android resource folders</td>
            <td><code>values-</code> with <code>r</code> before region, or <code>b+</code> for full tags</td>
            <td><code>values-pt-rBR</code>, <code>values-b+zh+Hant</code></td>
        </tr>
        <tr>
            <td>iOS and macOS <code>.lproj</code></td>
            <td>BCP 47-like</td>
            <td><code>pt-BR.lproj</code>, <code>zh-Hans.lproj</code></td>
        </tr>
    </tbody>
</table>

<p>
    Pick BCP 47 as the canonical form in your codebase, and convert at the edges only where a platform demands something else. A single normalization function at the boundary prevents <code>pt_BR.json</code>, <code>pt-br.json</code>, and <code>pt-BR.json</code> from all appearing in the same repo. For how those files should be structured, see <a href="/blogs/json-translation-files-explained/">JSON translation files explained</a>.
</p>

<h2>Unicode Extensions: Calendars, Digits, and Clocks</h2>

<p>
    The <code>-u-</code> extension lets you override locale defaults without changing the language. These are the keys worth knowing:
</p>

<pre><code>// Force Latin digits for Egyptian Arabic (the default is Arabic-Indic)
new Intl.NumberFormat("ar-EG-u-nu-latn").format(1234.5); // "1,234.5"

// Force a 24-hour clock for US English
new Intl.DateTimeFormat("en-US-u-hc-h23", { timeStyle: "short" }).format(date); // "15:00"

// Thai defaults to the Buddhist calendar; -u-ca-gregory overrides it
new Intl.DateTimeFormat("th-TH", { year: "numeric" }).format(date); // "พ.ศ. 2569"</code></pre>

<ul>
    <li><code>ca</code>: calendar (<code>gregory</code>, <code>buddhist</code>, <code>islamic</code>, <code>japanese</code>)</li>
    <li><code>nu</code>: numbering system (<code>latn</code>, <code>arab</code>, <code>deva</code>)</li>
    <li><code>hc</code>: hour cycle (<code>h12</code>, <code>h23</code>)</li>
    <li><code>co</code>: collation for sorting (<code>phonebk</code> for German phonebook order)</li>
</ul>

<p>
    These pair naturally with the formatters covered in our <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">guide to the JavaScript Intl API</a>. They are ideal for user preferences: store the user's chosen language and their clock and digit preferences separately, and combine them into one tag at render time.
</p>

<h2>Locale Negotiation: Matching Users to What You Support</h2>

<p>
    A browser sends something like <code>Accept-Language: de-AT,de;q=0.9,en;q=0.8</code>. You support <code>en</code>, <code>de</code>, and <code>pt-BR</code>. Locale negotiation is the step that picks <code>de</code>. Get it wrong and Austrian users see English even though you have German.
</p>

<p>
    The core idea, from RFC 4647's lookup algorithm, is progressive truncation: try the full tag, then strip subtags from the right until something matches.
</p>

<pre><code>const SUPPORTED = ["en", "de", "pt-BR", "zh-Hans", "zh-Hant"];
const DEFAULT = "en";

function negotiate(requested) {
  for (const tag of requested) {
    let loc;
    try {
      loc = new Intl.Locale(tag).maximize(); // zh-TW -> zh-Hant-TW
    } catch {
      continue; // skip malformed tags such as "en_US"
    }
    const candidates = [
      loc.toString(),
      `${loc.language}-${loc.script}`,
      `${loc.language}-${loc.region}`,
      loc.language,
    ];
    const match = candidates.find((c) =>
      SUPPORTED.some((s) => s.toLowerCase() === c.toLowerCase())
    );
    if (match) return SUPPORTED.find((s) => s.toLowerCase() === match.toLowerCase());
  }
  return DEFAULT;
}

negotiate(["de-AT", "en"]); // "de"
negotiate(["zh-TW"]);       // "zh-Hant"
negotiate(["zh-SG"]);       // "zh-Hans"
negotiate(["pt-PT"]);       // "en" (pt-PT is not pt-BR; no plain "pt" supported)
negotiate(["fr-FR"]);       // "en"</code></pre>

<p>
    Notice the <code>pt-PT</code> case. Whether a Portuguese user should get Brazilian Portuguese or English is a product decision, not something to leave to an accident of string matching. If you want <code>pt-PT</code> to fall back to <code>pt-BR</code>, add an explicit fallback map. For production use, <a href="https://formatjs.github.io/docs/polyfills/intl-localematcher/" target="_blank" rel="noopener noreferrer"><code>@formatjs/intl-localematcher</code></a> implements the full CLDR-based matching, and frameworks like next-intl run it for you in middleware.
</p>

<h3>Negotiation is a suggestion, not a redirect</h3>

<p>
    Use the negotiated locale for first-time visitors on a neutral URL. Once a user picks a language, store it and respect it over <code>Accept-Language</code>. And if they landed on a specific localized URL such as <code>/de/pricing</code>, never redirect them away from it. Our <a href="/blogs/hreflang-multilingual-seo-guide/">hreflang and multilingual SEO guide</a> explains why automatic redirects also block search crawlers.
</p>

<h2>Locale Codes in hreflang</h2>

<p>
    hreflang uses BCP 47 syntax, but search engines support a subset. Google's documentation specifies an ISO 639-1 language code plus an optional ISO 3166-1 alpha-2 region, with script subtags such as <code>zh-Hant</code> also accepted. Two consequences:
</p>

<ul>
    <li><code>en-UK</code> is a common mistake that Google calls out specifically. Use <code>en-GB</code>.</li>
    <li>Numeric UN M.49 regions such as <code>es-419</code> are valid BCP 47 but are not supported as hreflang regions. Use <code>es</code> for a general Spanish page, or list specific countries (<code>es-MX</code>, <code>es-AR</code>) pointing at the same URL.</li>
</ul>

<p>
    Some libraries silently alias invalid codes. <code>new Intl.Locale("en-UK")</code> returns <code>en-GB</code> in current engines, which hides the bug in your app while the raw string still ships in your HTML. Validate the tags you emit, not just the ones you parse.
</p>

<h2>A Locale Code Checklist</h2>

<ul>
    <li>Use BCP 47 with hyphens as the canonical form everywhere in your code and file names.</li>
    <li>Start with language-only tags; add regions only where content differs.</li>
    <li>Use <code>zh-Hans</code> and <code>zh-Hant</code> for Chinese, not <code>zh-CN</code> and <code>zh-TW</code>.</li>
    <li>Double-check language versus country codes: <code>ja</code>, <code>ko</code>, <code>sv</code>, <code>da</code>, <code>cs</code>, <code>el</code>, <code>uk</code>, <code>en-GB</code>.</li>
    <li>Normalize incoming tags with <code>Intl.getCanonicalLocales()</code> and catch the <code>RangeError</code> for malformed input.</li>
    <li>Negotiate with truncation fallback, and make cross-region fallbacks such as <code>pt-PT</code> to <code>pt-BR</code> explicit decisions.</li>
    <li>Set <code>&lt;html lang&gt;</code> to the same tag you used to load the translations.</li>
</ul>

<h2>Frequently Asked Questions</h2>

<h3>What is the difference between en-US and en_US?</h3>
<p>
    <code>en-US</code> is a BCP 47 language tag, the standard used by browsers, HTML, HTTP, and the JavaScript Intl API. <code>en_US</code> is the POSIX locale format used by Linux, gettext, and older Java and ICU APIs. Use hyphens on the web; the Intl API throws a RangeError for the underscore form.
</p>

<h3>Should I use zh-CN or zh-Hans?</h3>
<p>
    Use <code>zh-Hans</code> for Simplified Chinese and <code>zh-Hant</code> for Traditional Chinese. The meaningful difference is the writing system, and script tags describe it correctly for every region, including Singapore, Hong Kong, and overseas readers.
</p>

<h3>Is en-UK a valid locale code?</h3>
<p>
    No. The ISO 3166-1 code for the United Kingdom is GB, so British English is <code>en-GB</code>. Some libraries silently alias <code>en-UK</code>, but Google's hreflang documentation lists it as a common error.
</p>

<h3>What does es-419 mean?</h3>
<p>
    <code>es-419</code> is Latin American Spanish. The region 419 is the UN M.49 numeric code for Latin America and the Caribbean. It is valid BCP 47 and widely used for translation files, but Google does not support numeric regions in hreflang.
</p>

<h3>How do I detect a user's preferred language?</h3>
<p>
    Read <code>navigator.languages</code> in the browser or the <code>Accept-Language</code> header on the server, then negotiate against your supported locales using progressive fallback. Treat the result as a default for first-time visitors and always let an explicit user choice override it.
</p>

<h2>The Short Version</h2>

<p>
    A locale code is language, optionally script, optionally region, joined by hyphens. Keep tags as short as your content allows, use scripts for Chinese and Serbian, watch the language-versus-country traps, and negotiate with fallback instead of exact string matching. Get the code right once at the boundary and every formatter, translation file, and hreflang tag downstream inherits it.
</p>

<p>
    Adding a new locale should be a one-line config change, not a hunt through file names. <a href="/">Babelize</a> generates and syncs translation files for every locale you configure, directly in your repository.
</p>
---
- [All articles](https://www.babelize.co/blogs)