How are locale identifiers structured, and how does the system match a user’s language to the localisations you ship?
Predict first
Which localisation does each preference select?
import Foundation
let shipped = ["en", "zh-Hans", "zh-Hant", "pt-PT", "pt", "es", "es-419", "nb"]
for p in ["zh-Hant-TW", "zh-HK", "zh-Hans-CN", "pt-BR", "pt-PT", "en-GB", "es-MX", "nb-NO", "fr-CA"] {
print(p, "->", Bundle.preferredLocalizations(from: shipped, forPreferences: [p]))
}
print(Bundle.preferredLocalizations(from: ["en", "fr"], forPreferences: ["de-DE", "fr-CA"]))
let l = Locale(identifier: "zh-Hant-HK")
print(l.language.languageCode?.identifier ?? "-", l.language.script?.identifier ?? "-", l.region?.identifier ?? "-")
print(Locale.Language(identifier: "zh-TW").maximalIdentifier, Locale.Language(identifier: "sr-RS").maximalIdentifier)
print(Locale(identifier: "en-US-u-ca-buddhist-nu-thai").calendar.identifier, Locale(identifier: "en-US-u-ca-buddhist-nu-thai").numberingSystem.identifier)
print(Locale(identifier: "en_US@rg=gbzzzz").region?.identifier ?? "nil")
- zh-Hant-TW -> ["zh-Hant"] … pt-BR -> ["pt"] … es-MX -> ["es-419", "es"] … fr-CA -> ["en"]
- zh-Hant-TW -> ["en"]
- pt-BR -> ["pt-PT"]
Mental model. Matching is a postal sorting office: it reads the address from the most specific part, falls back to the town, and finally to the head office.
A locale identifier is built from a language, an optional script and a region (zh-Hant-HK is Chinese, Traditional script, Hong Kong), optionally followed by extension keys such as -u-ca-buddhist-nu-thai (calendar and numbering system, which the check turns into the Buddhist calendar and Thai digits) or a region override (@rg=gbzzzz makes the region GB). Many tags are shorthand: zh-TW is really zh-Hant-TW and sr-RS is Cyrillic Serbian (sr-Cyrl-RS), which maximalIdentifier reveals. Treat identifiers as structured values (Locale.Language, Locale.Region) rather than splitting strings on an underscore.
The bundle then matches the user’s ordered preference list against the localisations you ship, using these rules (all verified in the check for the shipped set of English, Simplified and Traditional Chinese, Portugal and generic Portuguese, Spanish, Latin American Spanish and Norwegian Bokmål). Traditional Chinese for Taiwan and Hong Kong picks zh-Hant and mainland picks zh-Hans. Brazilian Portuguese gets generic pt while pt-PT gets its own, with pt as the second choice. Mexican Spanish lands on es-419. An unsupported language (French Canada when you ship no French) falls back to your development language, but a list with a later match, German then French, picks the French. The consequence: ship zh-Hans and zh-Hant, not zh; ship regional variants only where the wording genuinely differs; and remember that the language list picks the strings while the region still picks the formats (Q82, Q111). The result is for the process; the per-app language setting changes it at the next launch.
Script and region fall back through the table; unsupported languages reach the development language.
Common traps
- Shipping a bare
zhlocalisation. Simplified and Traditional users cannot be told apart by language code alone. - Parsing a locale by splitting on “_” or “-”. Scripts and extensions break it.
They might follow up with
- Which language does the user get if mine is missing? The next in their list, then the development language.
- Does the region override change language? No, only the region-based conventions.
Related questions
- 2.2.6 What is a Locale, and how is it different from the user’s language?
- 2.6.5 How does the app pick its language at runtime, and how is that different from Locale?
- 24.4.1 How do you test internationalisation before translations exist: pseudolanguages, launch arguments, text…
- 24.8.2 How do plural categories work across languages, and what does a stringsdict or catalog entry have to contain?
How do plural categories work across languages, and what does a… →