> Uploading knowledge... _
[░░░░░░░░░░░░░░░░░░░░░░░░] 0%
blog logo
> CHICIO CODING_Pixels. Code. Unplugged.

Which Locale? Decoding the Madness Behind iOS Localization and Language Preferences

·

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!

How does the locale work on iOS?

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:

  • If the user selects a language tied to a specific country (a dialect), then the locale is simply the first entry in the Preferred Languages list.
    • Example: Choosing English (UK) sets the locale to en-GB.
  • If the user selects a generic language, the locale is a combination of the Preferred Language and Region settings.
    • Example: Choosing 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 😅.
iOS language and region settings
iOS language and region settings

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:

  • iOS walks through the user’s Preferred Languages list (from first to last) looking for a match among the app’s supported localizations (the .lproj folders, or .xcstrings entries in modern Xcode 16+).
  • If the preferred language is a regional variant not directly supported by the app (e.g., en-IN), iOS tries to fall back to a closer match before moving on to the next preferred language.
  • If no preferred language matches at all, iOS defaults to the app’s development region (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.

ICU and CLDR: the engine behind locale matching

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.

The language distance matching 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:

LocaleExpanded form
enen-Latn-US
en-INen-Latn-IN
en-GBen-Latn-GB
en-IEen-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):

DesiredSupportedDistance
en_*_GBen_*_* (any other English region)4
en_*_001 (World English)en_*_*4
en_*_USen_*_* (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.

A word of caution from Apple

Apple explicitly warns developers not to hardcode fallback details in their apps based on these rules. The reasons are straightforward:

  • Rules can change with ICU/CLDR updates across OS versions
  • The matching behavior is not officially documented at this level of detail
  • It’s considered an implementation detail

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.

Retrieving the Device and App Locale

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).
    • Related to this, the "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).
    • According to Apple’s documentation, the returned locales are ordered based on the user’s language preferences, so based on the sorting of the Preferred Languages selected.
    • The first entry in this list represents the locale selected by the app, based on the user’s device settings and the localizations available in the app bundle.

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?" app: locale APIs in action

"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:

  • "English"
  • "English (India)"
  • "English (Ireland)"
  • "English (United Kingdom)"
  • "German"
  • "Italian"
  • "Italian (Switzerland)"

By default, the app’s locale is set to "English (United Kingdom)".

"Which Locale?" supported locales
"Which Locale?" supported locales

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
"Which Locale?" Info.plist
"Which Locale?" Info.plist

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:

  • the Locale.preferredLanguages
  • the "AppleLanguages" entry in UserDefaults

The app locale section displays:

  • the Bundle.preferredLocalizations, that contains the locale selected by the app based on the device settings
  • the Bundle.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.

An example with the default locale set at device level, that matches the app locale
An example with the default locale set at device level, that matches the app locale

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.

An example of dialect (language + region) selected as language and region that doesn't match the dialect one.
An example of dialect (language + region) selected as language and region that doesn't match the dialect one.

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.

An example with language without dialect (no region specified in the language) an a region that combined with the language created an app supported locale
An example with language without dialect (no region specified in the language) an a region that combined with the language created an app supported locale

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:

  • translate everything at system level with the "en" language
  • format numbers, dates etc. according to the region, in this case "IT"

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.

An example with a language without dialect and a region that doesn't match in combination any locale supported by the app
An example with a language without dialect and a region that doesn't match in combination any locale supported by the app

Let's now explore some more complex cases involving multiple preferred languages, where the device locale configuration includes:

  • a preferred language selected not supported by the app, eg. fr
  • a second preferred language supported by the app, eg. it
  • a region not supported by any locale in the app, eg. FR

This 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.

An example of device locale not supported by the app, but with a second preferred language supported
An example of device locale not supported by the app, but with a second preferred language supported

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.

An example with a device locale not supported by the app
An example with a device locale not supported by the app

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.

An example with a device locale not supported by the app, but with a country supported by another locale in the app
An example with a device locale not supported by the app, but with a country supported by another locale in the app

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.

Sources

Here are the key references we used to understand the CLDR/ICU language distance matching mechanism:

Conclusion

The 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 😆).