A locale is the set of parameters describing a user’s language, region and formatting preferences. It is the answer to “who is this person and how should content be presented to them”, and it controls considerably more than which language the words are in.
Usually a locale consists of at least a linguistic identifier and a regional one: English + United States gives en-US.
What a locale actually controls
Language is the visible part. The rest is where the bugs live.
| Aspect | en-US |
de-DE |
fr-CA |
ar-EG |
|---|---|---|---|---|
| Date | 3/4/2026 | 4.3.2026 | 2026-03-04 | ٤/٣/٢٠٢٦ |
| Decimal separator | 1,234.56 | 1.234,56 | 1 234,56 | ١٢٣٤٫٥٦ |
| Currency | $1,234.56 | 1.234,56 € | 1 234,56 $ | ١٢٣٤٫٥٦ ج.م. |
| First day of week | Sunday | Monday | Sunday | Saturday |
| Text direction | LTR | LTR | LTR | RTL |
| Plural forms | 2 | 2 | 2 | 6 |
Note the top-left cell against the third column: 3/4/2026 is March 4th in the United States and April 3rd in most of the rest of the world. A date rendered without locale awareness is not merely ugly, it is wrong in a way nobody notices until a booking is off by a month.
Beyond formatting, a locale determines:
- Sorting. Swedish sorts ä after z; German sorts it alongside a. Byte-order sorting is wrong for both, and “wrong” here means a user cannot find their own name in a list.
- Case conversion. Turkish has dotted and dotless i, so uppercasing
igivesİintrandIeverywhere else. This breaks case-insensitive comparison in ways that have taken down real systems. - Plural categories. How many forms a message needs, from one to six.
- Name and address order, units of measurement, phone number formats, paper size.
How a language tag is built
BCP 47 is the IETF standard defining the structure. Subtags run most-general to most-specific, separated by hyphens:
language[-Script][-REGION][-variant]
| Subtag | Standard | Convention | Examples |
|---|---|---|---|
| Language | ISO 639-1, else 639-2/3 | lowercase | en, fr, zh, fil |
| Script | ISO 15924 | Titlecase, four letters | Latn, Cyrl, Arab, Hans |
| Region | ISO 3166-1 alpha-2, or UN M.49 | UPPERCASE | GB, CA, BR, 419 |
| Variant | IANA registry | lowercase | valencia, 1901 |
Reading a few real tags:
pt-BR— Portuguese as written in Brazil.sr-Latn-RS— Serbian, Latin script, Serbia. Serbian is written in both Cyrillic and Latin, so the script subtag is doing real work.es-419— Spanish for Latin America.419is a UN M.49 region code covering a continent rather than a country, which is exactly right when the alternative is picking one country arbitrarily.zh-Hans— Simplified Chinese, no region. Usually what you want, because Chinese splits by script rather than by country.
Tags are formally case-insensitive, but follow the casing conventions anyway: plenty of software that reads your files is stricter than the standard it claims to implement.
The separator is a hyphen in the standard and an underscore in POSIX, Java and gettext filenames — a split with its own long history, covered in en-GB vs en_GB.
Choosing how specific to be
The most common design mistake is being more specific than you can support. Adding en-US, en-GB, en-AU and en-CA means four sets of translations to maintain, and if you only actually vary spelling in two of them you have created three-quarters of the work for a quarter of the benefit.
Useful heuristics:
Start with the language alone (fr, es, de) unless you have a concrete reason to split. You can add regions later; merging them afterwards is harder.
Split when the content genuinely differs. pt-BR and pt-PT differ enough in vocabulary and grammar that Brazilian users notice European Portuguese immediately. es-ES and es-MX differ meaningfully. en-US and en-CA mostly do not.
Split by script where the language uses more than one. zh-Hans / zh-Hant, sr-Latn / sr-Cyrl. This is a genuine reading-comprehension issue, not a preference.
Let formatting and translation vary independently. A user can want French text with Canadian date formats. Formatting comes from the locale; the translation can come from a parent locale — which is what fallback is for.
Fallback and matching
Users arrive with a preference that may not exactly match anything you support. A browser sends:
Accept-Language: fr-CA, fr;q=0.9, en;q=0.8
Read as: Canadian French preferred, then any French, then English. The rules for resolving this are worth implementing properly rather than by hand:
Lookup takes the requested tag and progressively truncates it until something matches: fr-CA → fr → default. Simple and usually right.
Filtering returns everything matching a prefix — asking for fr gives fr, fr-CA, fr-BE. Useful for offering a user a choice.
The trap is that truncation is not always correct. zh-Hant-HK truncated to zh may land on Simplified, which a Traditional reader cannot comfortably read. CLDR publishes explicit fallback data for these cases, and any mature i18n library uses it.
Inheritance is the same idea applied to your own content. Define generic French in fr, then create fr-CA carrying only the strings that genuinely differ, and let the rest inherit. You maintain one full translation plus a small delta instead of two full translations that drift apart. WebTranslateIt implements this directly: an inherited language’s files can either include the parent’s translations or contain only the differences with the rest left null.
Custom locales
Sometimes you need a locale that does not exist. The common case is a review stage: a en-dev pseudo-language holding copy written by developers, to be rewritten by a professional before it is translated. Another is pseudo-localization, which uses a fake locale to test that internationalization actually worked.
WebTranslateIt supports this by taking a base language and appending a suffix code and description, producing for example en_dev named “English by developers”.
Locale codes teams get wrong
A short list of the ones that recur, because each has cost somebody a release:
en-UKdoes not exist. The ISO 3166 code for the United Kingdom isGB.zh-CNused to mean Simplified. It implies it without stating it, and leaves Simplified readers in Singapore and Malaysia unmatched. Usezh-Hans.iw,in,ji,jware the pre-1989 codes for Hebrew, Indonesian, Yiddish and Javanese. ISO renamed them; Java froze the old ones for compatibility and still returnsiwfromLocale("he"). Normalise these aliases explicitly at any JVM boundary.- A country code is not a language code.
ATis Austria; the locale isde-AT. This surfaces when a country picker is wired straight into a locale lookup. noversusnb/nn. Norwegian has two written standards, Bokmål and Nynorsk.nois the macrolanguage;nbis what most content actually is.
Practical rules
- Store locales in hyphenated BCP 47 form. Convert to underscores only at the filesystem boundary, for the tools that need it.
- Normalize once, at the edge. Never let a raw locale string from user input or a third-party API reach your lookup tables.
- Never build a locale by concatenating a language picker and a country picker.
de-BRis a valid-looking tag for a combination nobody supports. - Do not infer locale from IP address alone. A German speaker in Japan wants German. Use
Accept-Language, remember the user’s explicit choice, and treat geography as a weak hint at best. - Keep formatting locale and content locale separate in your data model. They diverge more often than you would expect.
Frequently asked questions
- What is a locale?
- A locale is the set of parameters describing a user's language, region and formatting preferences. It determines not just which language text appears in but how dates, numbers, currency and names are formatted, how strings sort, and which plural forms apply.
- What is the difference between a language and a locale?
- A language identifies the words (fr). A locale adds the region, script or variant that changes how the language is written or formatted (fr-CA). French in Canada and French in France share a language and differ in vocabulary, date format and some spelling.
- What is BCP 47?
- BCP 47 is the IETF standard defining the structure of language tags — a language subtag followed by optional script, region and variant subtags separated by hyphens, as in sr-Latn-RS. It is what HTML lang attributes, HTTP Accept-Language headers and JavaScript Intl APIs all expect.
- Should I use zh-CN or zh-Hans?
- Prefer zh-Hans for Simplified Chinese and zh-Hant for Traditional. Chinese splits by script rather than by country, so a region tag like zh-CN only implies the script and leaves Simplified-reading users elsewhere unmatched.
Keep reading
-
en-GB vs en_GB: hyphen or underscore in locale codes?
The standard says hyphen, half your toolchain says underscore. Here is which format each ecosystem expects, why the split exists, and how to convert between them safely.
-
Plural rules by language: the complete CLDR guide
A reference table of CLDR plural categories for 163 languages, plus why one does not mean 1 and how each i18n framework expects you to spell the rules.
-
i18n vs l10n: what's the difference?
Internationalization is the engineering work that makes translation possible. Localization is the translation itself. Here is where the line falls and why it matters.
-
Locale codes, language tags & plural rules (documentation)
Building locales in WebTranslateIt, custom locales, and language inheritance.
Translate your app without the spreadsheet round-trip
WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.