
·
iOS localization is a wild ride where device and app locales play by their own rules. Under the hood, Apple uses CLDR/ICU language distance matching to pick the best locale for your app. Let’s decode the madness together.
In the past few weeks, I’ve been working with my colleague Antonino Gitto, a senior software engineer with over five years of experience in mobile app development, and Marco De Lucchi (you might remember him from our previous post about iOS widgets) on improving how the lastminute.com mobile apps handle localization. And we’re doing it for a very special reason (that we cannot share today): something exciting is coming in the next few months, so stay tuned! 😏
At lastminute.com, many configurations depend on the user’s locale. A clear example is currency, which is explicitly
determined by these parameters. Until now, we’ve been using
react-native-localization to manage localization in our app.
However, the method provided by this library to retrieve the locale reads the AppleLanguages user default.
We conducted several experiments to understand the logic behind the values stored in this user default, only to be astonished by the results: none of the returned values was matching our expectation in terms of locale with respect to the ones we defined in our apps. That’s when we realized we needed to gain a deeper understanding of how iOS determines a user’s locale.
This marked the beginning of our journey down the rabbit hole of iOS localization world. Along the way, we learned a lot about how iOS selects a locale for apps with multiple locales defined in its bundle.
Join us on this wild ride and avoid falling into the same traps we did when dealing with iOS locale madness!
iOS operates with two different levels of locale: device locale and app locale.
The device locale is set in Settings → General → Language & Region. It can either be selected directly from the Preferred Languages menu or derived from a combination of Preferred Languages and Region.
Here’s how it works in detail, and how iOS chooses the device locale based on these two settings:
English (UK) sets the locale to en-GB.English (generic) and Italy as the region results in a locale of en-IT. Even if this is not
a locale that exists, it still makes sense as pointed by an Apple engineer in this post.
This can happen because you can have your native language set as the preferred one, but have the region set to
something else (eg. because for example you live abroad). Anyway, it is strange to see this "non sense" locale
created by iOS by combining the two fields above 😅.
The app locale is the locale the app uses to select translations. This is determined through a specific algorithm (which we’ll call the app locale algorithm), as explained in this Apple documentation article (which, by the way, wasn’t easy to find! 😅). The article gives a simplified overview of the process:
.lproj folders, or .xcstrings entries in modern Xcode 16+).en-IN), iOS tries to
fall back to a closer match before moving on to the next preferred language.CFBundleDevelopmentRegion).Reading this, you might assume the "fallback" step is a simple operation: strip the region and look for the base
language (e.g., en-IN -> en). But that’s not what iOS actually does. The fallback mechanism is powered by
CLDR/ICU language distance matching, which computes a linguistic distance between the user’s locale and each
locale supported by the app, then picks the one with the lowest distance. For example, with a device locale set to
en-IN and an app that supports both en and en-GB, iOS will pick en-GB over en — because Indian English is
linguistically closer to British English than to American English. This might seem counterintuitive at first, but it
makes perfect sense once you understand the algorithm behind it.
Let’s dive into how this works.
The locale matching that Apple describes in the documentation excerpt above is powered by two key components from the Unicode ecosystem.
ICU (International Components for Unicode) is an open-source C/C++ and Java library
maintained by the Unicode Consortium. It provides locale matching, formatting (dates, numbers, currencies), collation,
and full Unicode support. ICU is used by Apple, Google, Microsoft, Adobe, Meta, and many other major tech companies.
Apple integrates ICU directly into Darwin (the core of macOS and iOS). This is confirmed by an
Apple DTS Engineer on the Apple Developer Forums, who revealed that
the locale matching implementation uses ualoc_localizationsToUse from ICU internally. Apple also exposes
Locale.IdentifierType.icu in
Foundation, further confirming the connection.
CLDR (Unicode Common Locale Data Repository) is the database of localization data
maintained by the Unicode Consortium. Among many things, it contains the language distance tables that ICU uses
for matching. These distances are hardcoded data in the supplemental languageInfo.xml file (they are not calculated
at runtime).
The chain works like this: CLDR (data) -> ICU (library) -> Apple Foundation (uses ICU) ->
Bundle.preferredLocalizations. So when Apple’s documentation says "iOS attempts to fall back to a more generic
language", the actual mechanism deciding which fallback is "more generic" (or rather, linguistically closer) is
the CLDR/ICU language distance algorithm.
So how does this language distance matching actually work? The algorithm operates in three steps.
Step 1: Likely subtags (expansion). Before calculating any distance, each locale is expanded to its full
language-script-region form using CLDR’s "likely subtags" table. This is critical because it gives every locale
a complete identity for comparison:
| Locale | Expanded form |
|---|---|
en | en-Latn-US |
en-IN | en-Latn-IN |
en-GB | en-Latn-GB |
en-IE | en-Latn-IE |
The critical point here: en without a region becomes en-Latn-US (American English). This is extremely
important for understanding the matching behavior.
Step 2: Field-by-field comparison. The total distance between two locales is computed as:
total distance = distance(language) + distance(script) + distance(region)
Each field (language, script, region) is compared using lookup tables in CLDR.
Step 3: Special distance rules. CLDR defines specific distance rules that reflect linguistic relationships between locales. For English, these rules are particularly interesting (source: CLDR Language Matching Charts):
| Desired | Supported | Distance |
|---|---|---|
en_*_GB | en_*_* (any other English region) | 4 |
en_*_001 (World English) | en_*_* | 4 |
en_*_US | en_*_* (any other English region) | 6 |
en_*_* | en_*_* (generic) | 5 |
Notice something? American English (en_*_US) has the highest distance (6) from other English variants. This is
not a bug. Most English variants worldwide derive from British English (India, Australia, South Africa, Ireland, New
Zealand, Singapore...). American English is the exception. CLDR reflects this real-world linguistic reality by giving
en-GB a "closer" relationship to most other English variants.
CLDR also defines "paradigm locales" (reference locales considered first in matching): en-GB, es-419, pt-BR,
zh-Hant. This gives additional priority to en-GB as an "attractor" for non-American English variants.
The default distance threshold is 50. If the distance exceeds this value, the match is discarded entirely.
Here’s the fallback chain for some common English locales, as confirmed by the
Apple DTS Engineer.
Pretty interesting, right? 😏 This means that for most English regional variants, iOS will prefer en-GB over
a generic en (which is actually en-US) when both are available.
Apple explicitly warns developers not to hardcode fallback details in their apps based on these rules. The reasons are straightforward:
So while understanding these mechanics is incredibly useful for debugging, you should always rely on
Bundle.preferredLocalizations and let iOS handle the matching for you.
Now that we understand how iOS selects the locale, how do we retrieve this information in code? There are a few key APIs in the iOS SDK:
Locale.preferredLanguages: returns the list of available device locales. The first entry represents the device’s selected locale (i.e., the system-wide locale).
"AppleLanguages" entry in UserDefaults stores the same data and is used by react-native-localization to retrieve the locale.Bundle.main.preferredLocalizations: returns locales included in the app bundle (as defined in the Xcode project).
The user can also manually override the app’s locale using the app-specific language settings. However, this menu is
only available if multiple languages are selected in Preferred Languages. If you want the menu to always be
available, you can force it by adding UIPrefersShowingLanguageSettings to Info.plist.
Clear as mud, right? 😆
To illustrate all of this, let’s explore some real-world examples using a small one-screen testing app we built called Which Locale?.
"Which Locale?" is a simple, single-screen app designed to help us compare the different locale settings discussed earlier and observe how they behave when the user changes either the device or app locale. Let’s walk through the Xcode project setup and the implementation of this app.
The app supports 7 different locales:
By default, the app’s locale is set to "English (United Kingdom)".

In the Info.plist of the project we also added some settings:
CFBundleDevelopmentRegion or usually called "Default localization" to be the $(DEVELOPMENT_REGION), that is
basically the "English (United Kingdom)" default chosen in the previous screen.UIPrefersShowingLanguageSettings to true, that as we will see later will enable a cool feature related to the app
language menu
As we mentioned before, the app consists of a single view, structured into sections that display the device and app locale settings using relevant iOS APIs.
The device locale section retrieves and displays:
Locale.preferredLanguages"AppleLanguages" entry in UserDefaultsThe app locale section displays:
Bundle.preferredLocalizations, that contains the locale selected by the app based on the device settingsBundle.main.localizations, that contains the locales configured in the screen shown before where it is possible
to add locales supported by the app.import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
List {
Section(header: Text("Device User Locale").font(.title2).bold()) {
LocaleSectionView(title: "Locale.preferredLanguages", languages: Locale.preferredLanguages, showCurrent: true)
LocaleSectionView(title: "UserDefaults AppleLanguages", languages: getAppleLanguagesArray(), showCurrent: true)
}
Section(header: Text("App Locale").font(.title2).bold()) {
LocaleSectionView(title: "Bundle.preferredLocalizations", languages: Bundle.main.preferredLocalizations, showCurrent: true)
LocaleSectionView(title: "Bundle.localizations", languages: Bundle.main.localizations, showCurrent: false)
}
Section {
Text("mobile_app.greetings")
.font(.headline)
.foregroundColor(.blue)
}
}
.navigationTitle("Which Locale?")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: openAppSettings) {
Label("Settings", systemImage: "gear")
}
}
}
}
}
private func openAppSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
private func getAppleLanguagesArray() -> [String] {
if let appleLanguages = UserDefaults.standard.array(forKey: "AppleLanguages") as? [String] {
return appleLanguages
}
return ["Not Found"]
}
}
struct LocaleSectionView: View {
let title: String
let languages: [String]
let showCurrent: Bool
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title).bold()
if showCurrent, let primary = languages.first {
Text("Current: \(primary)")
.font(.headline)
.foregroundColor(.blue)
}
ForEach(languages, id: \.self) { language in
Text(language)
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
}
}
}
}
By running the app and modifying the device language settings or overriding the app-specific language settings, we can observe how iOS dynamically adjusts both the device locale and the app locale based on user preferences.
Let's start with a simple case: language selected "English (United Kingdom)" and region "United Kingdom". In this case
Locale.preferredLanguages (which always matches AppleLanguages) returns en-GB and
also Bundle.preferredLocalization returns en-GB. This is a straightforward exact match (CLDR distance 0) — the
device locale en-GB is directly supported by the app, so no distance calculation is needed.

Now, let's add some complexity. This time, we will select "English (United Kingdom)" as language but set the region
to "Italy". What happens in this case? The locale at both device and app level is still "English (United Kingdom)".
Why? Because the language selected is already a specific dialect, that represents itself a locale (it's a combination
of language and region). So the "region" setting is completely ignored, and Bundle.preferredLocalization will return
en-GB again — still an exact match, still CLDR distance 0.

The next case is another interesting one. The language selected is "English", without specifying a particular dialect,
and the region selected is "Ireland". In this case, the device locale is calculated as a combination of language and
region, resulting in en-IE. Since our app supports en-IE, this is again an exact match (CLDR distance 0). Both
Locale.preferredLanguages and Bundle.preferredLocalization return en-IE.

Now, let's see what happens when there is no exact match and CLDR distance matching really kicks in. What happens
if we select "English" (without a specific dialect) and choose a region not associated with that language in our app,
like, for example, "Italy"? In this case we encounter the first mismatch between Locale.preferredLanguages and
Bundle.preferredLocalization. The first one will return en-IT, so a very basic combination of the preferred
language and region device settings. This allows iOS to:
Even though this behavior might seem counterintuitive at first, it actually makes sense. A user with this configuration might be a native English speaker living in Italy, so iOS attempts to mix and match the language and region settings accordingly.
On the other hand, Bundle.preferredLocalization returns en-GB. Why? This is where the CLDR/ICU language distance
matching we discussed earlier kicks in. Let's walk through the calculation:
en-IT (desired) vs en-GB (supported):
language: en → en = 0
script: Latn → Latn = 0
region: IT → GB = 4 (special CLDR rule: en_*_GB has distance 4 from other EN variants)
TOTAL = 4 ← WINS
en-IT (desired) vs en-IE (supported):
language: en → en = 0
script: Latn → Latn = 0
region: IT → IE = 5 (generic EN region-to-region distance)
TOTAL = 5
en-IT (desired) vs en [= en-US after likely subtags] (supported):
language: en → en = 0
script: Latn → Latn = 0
region: IT → US = 6 (special CLDR rule: en_*_US has distance 6 from other EN variants)
TOTAL = 6
The en-GB locale wins because it has the lowest distance (4) to en-IT, thanks to CLDR's recognition that British
English is linguistically closer to most English regional variants than American English. This is not a simple fallback
to CFBundleDevelopmentRegion. It's a deliberate, data-driven decision by the matching algorithm.

Let's now explore some more complex cases involving multiple preferred languages, where the device locale configuration includes:
fritFRThis is very tricky (and a common case for users like me that have multiple preferred languages selected in the
settings). In this case iOS will return fr-FR as the locale from Locale.preferredLanguages. This makes sense
because at system level, the locale considers only the first (main) entry in the language settings when determining
the device locale. However fr-FR is not supported by the app, and the CLDR distance between fr and any of the
app's supported languages (en, de, it) is well above the threshold of 50 — they're completely different
languages. So iOS moves on to the next entry in the preferred languages list and finds it, which is an exact match
(distance 0). Bundle.preferredLocalization will return it as locale.

If we remove the it language as the second option from the preferred languages, the situation changes. Now the only
preferred language is fr, and CLDR distance matching can't find any supported locale close enough (the distance
between fr and en, de, or it is way above the threshold). Since no preferred language produces a match,
Bundle.preferredLocalization falls back to the CFBundleDevelopmentRegion (en-GB). This is the last resort in
the matching chain.

Last but not least, what happens if the preferred language in the system settings is not supported by the app but the
region is? For example, we have fr as preferred language and ch as region. In this case, even if we have a locale
that supports that region (it-CH) in the app, the CLDR distance between fr and it is still above the threshold
— sharing a region doesn't help when the languages are completely different. The region component only affects the
distance calculation when the language already matches (as we saw in the en-IT example, where the region determined
whether en-GB or en-US was closer). If no language matches at all, iOS falls back to CFBundleDevelopmentRegion.

That's it. As you can see, the cases are quite interesting, and once you start to fully understand the algorithm at the OS level, everything makes sense.
There's one last aspect to cover before wrapping up, related to how iOS manages app menu settings. Up until now, we've focused on the device settings, but what if you want the user to customize the app's language via the app’s settings section? You might expect iOS to make this easy, but... it doesn't. In fact, if the user has only one language selected in the preferred languages option in the device settings, the language menu in the app will not appear 😨. This can lead to frustrating situations where users are redirected to the settings section of the app, only to find there's nothing they can change 😅.
We were quite desperate about this issue, but then, buried in a WWDC 2024 video,
we found a solution. Do you remember when we mentioned we added UIPrefersShowingLanguageSettings to the Info.plist? With
this option, the language menu will always be visible in your app's settings. Additionally, if a user selects a locale
from the ones supported by your app, that locale will be added to the device’s preferred languages, either as a generic
language or a dialect, depending on how you've defined the locales in your app. Check out the video below to see a live
example of how this option works.
Here are the key references we used to understand the CLDR/ICU language distance matching mechanism:
ualoc_localizationsToUse from ICU internallyThe codebase for the "Which Locale?" app can be found in this GitHub repo. Feel free to experiment with it and run your own tests. Everything should be consistent with what we’ve covered in this post 🚀.
Understanding how CLDR and ICU work together behind the scenes is the key to fully grasping iOS locale behavior. Without that knowledge, some of the matching results look like bugs when they’re actually deliberate, linguistically-informed decisions.
We’ll do our best to keep this post updated if anything changes on Apple’s side. See you next time (we hope not again for the "locale madness" topic 😆).