← Back to Blog

Flutter Serving the Wrong Spanish? Fix Regional ARBs

flutterlocalizationarblocale-resolutiongen-l10n

Flutter Serving the Wrong Spanish? Fix Regional ARBs

You added app_es_MX.arb. You added Locale('es', 'MX') to supportedLocales. Your Mexican testers still get the Castilian strings from app_es.arb. Or worse — it works on your es-MX simulator and breaks for most of your real users, who are on es-419.

This isn't a bug. It's basicLocaleListResolution doing precisely what its source says it does. Below is the real algorithm, the ordering rule that fixes it, and why pt_BR is a trap of a completely different kind.

Step 0: what is the device actually requesting?

Most "flutter localization wrong locale" reports turn out to be a wrong assumption about the input. Print it:

import 'dart:io' show Platform;
import 'package:flutter/widgets.dart';

void dumpDeviceLocales() {
  final dispatcher = WidgetsBinding.instance.platformDispatcher;
  debugPrint('Platform.localeName       : ${Platform.localeName}');
  debugPrint('platformDispatcher.locale : ${dispatcher.locale}');
  for (final (i, l) in dispatcher.locales.indexed) {
    debugPrint('  [$i] $l  lang=${l.languageCode} '
        'script=${l.scriptCode} country=${l.countryCode}');
  }
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  dumpDeviceLocales();
  runApp(const MyApp());
}

Two sources, two different answers:

  • Platform.localeName (dart:io) is one string, and on Android it does not change while the app is running even if the user switches system language. Fine for a log line, useless for reacting to changes.
  • platformDispatcher.locales is the full ordered preference list, most-preferred first. This is what Flutter feeds into resolution — and it's the only place you'll see that your "Spanish" tester is really sending es_419.

Latin American Spanish is a single selectable system language on both Android and iOS, and it arrives with countryCode == '419' (the UN M.49 region code), not MX. Run the dump before you theorise.

How Flutter really resolves the locale

WidgetsApp tries, in order: your localeListResolutionCallback, then localeResolutionCallback, then the built-in basicLocaleListResolution. That default first hashes every entry in supportedLocales into four lookup maps — and every insert uses ??=, so the first entry wins each key. Then, for each device locale in preference order:

  1. Exact match on languageCode_scriptCode_countryCode.
  2. language + script — only attempted if the device locale carries a scriptCode.
  3. language + country — only attempted if the device locale carries a countryCode.
  4. language only — treated as a low-quality hit. It's stored and Flutter keeps scanning the next preferred locale for something better, unless it came from the device's first-choice locale and the next choice is a different language, in which case it returns immediately.
  5. country only, accumulated across the loop as a last resort. The source comment notes this fallback only really applies on iOS.
  6. supportedLocales.first if nothing matched at all.

Why Locale('es') swallows every es-MX device

With supportedLocales: [Locale('es')] and a device asking for es-419:

  • Pass 1 looks for the key es_null_419. Your only entry registered es_null_null. Miss.
  • Pass 2 skipped (no scriptCode).
  • Pass 3 looks for es_419. Your entry registered es_null. Miss.
  • Pass 4 finds languageLocales['es'] → Locale('es'), and since it came from the top preference, returns it instantly.

Castilian. Every time. This is the whole of "flutter supportedLocales country code ignored" — there is no fuzzy language-distance logic; the docs are explicit that the algorithm is optimised for speed and doesn't implement UTS #35 language matching.

The ordering rule nobody writes down

Because languageLocales[languageCode] ??= locale is first-wins, whichever es* entry appears first in supportedLocales becomes the catch-all for every unmatched Spanish region. gen-l10n sorts the generated AppLocalizations.supportedLocales alphabetically, which puts bare es ahead of es_MX — so es_AR, es_CO, es_PE and es_419 all land on Castilian by default.

Hoist your best default:

supportedLocales: const [
  Locale('en'),          // supportedLocales.first = last-resort default
  Locale('es', 'MX'),    // now the language-level catch-all for Spanish
  Locale('es'),          // still exact-matched by a device asking for plain "es"
  Locale('es', 'ES'),
  Locale('pt'),
  Locale('pt', 'PT'),
],

Two things worth checking against the trace above:

  • A device requesting plain es still hits Locale('es') at pass 1 (exact key es_null_null), so hoisting es_MX doesn't hijack it.
  • supportedLocales.first is your app's ultimate fallback for a device with no match at all. Keep your real default language there — don't accidentally make Mexican Spanish the default for Japanese users.

If you use the generated list, control the order from l10n.yaml instead of hand-writing it:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
preferred-supported-locales: [en, es_MX]

preferred-supported-locales hoists the named locales to the front of the generated list (default is alphabetical). Each one must have a real ARB file or generation throws. Open the generated app_localizations.dart afterwards and read the emitted supportedLocales — that list, in that order, is what resolution sees.

The pt_BR vs pt_PT trap is the opposite problem

flutter_localizations ships material_pt.arb and material_pt_PT.arbthere is no material_pt_BR.arb. Base pt is Brazilian: it says "Excluir", "Próximo mês", "Sobre o app". pt_PT is the European override: "Partilhar", "Secção inferior".

So if you name your files app_pt.arb (European) plus app_pt_BR.arb (Brazilian), a pt-PT device resolves to Locale('pt'), gets your European strings — and Brazilian Material tooltips underneath, because GlobalMaterialLocalizations receives the same resolved Locale and looks up material_pt.arb.

Mirror Flutter's own convention:

  • app_pt.arb → Brazilian Portuguese (the base)
  • app_pt_PT.arb → European overrides only

English works the same way: there's no material_en_US.arb, so en is US English and en_GB is the override. Spanish is the generous one — flutter_localizations ships material_es.arb, material_es_419.arb, material_es_MX.arb and 18 more country files, so once your resolved locale is right, the Material widget strings follow.

gen-l10n inheritance: keep regional ARBs small

gen-l10n emits class AppLocalizationsEsMx extends AppLocalizationsEs. That's plain Dart class inheritance: any getter you leave out of app_es_MX.arb falls through to app_es.arb at runtime. So a regional ARB should contain only the strings that actually differ — carro vs coche, celular vs móvil — never a copy of the whole base file.

One caveat you will hit. On Flutter stable today (3.44.x), those intentionally-omitted keys are still reported: they show up in untranslated-messages-file and in the "es_MX": N untranslated message(s) console warning. That's flutter/flutter#176020, opened 25 Sep 2025. It was fixed by PR #187950, "[gen_l10n] Exclude inherited keys from untranslated-messages-file", merged to master on 13 June 2026 — after the 3.44 branch cut, so it is not in the 3.44 stable series and lands in the next stable.

Until you're on that build, a key reported for es_MX is a genuine gap only if it's also reported for es. If you gate CI on this file, filter it:

jq '. as $m
  | to_entries
  | map(.key as $loc
        | ($loc | split("_")[0]) as $base
        | {($loc): (if $loc == $base then .value
                    else [ .value[] | select(IN(($m[$base] // [])[])) ] end)})
  | add
  | with_entries(select(.value | length > 0))' untranslated_messages.txt

Copy-paste: map es-419 → es-MX

Ordering alone already routes es-419 to es_MX via the language-only pass. A localeListResolutionCallback makes it explicit and survives someone re-sorting the list:

import 'package:flutter/material.dart';
import 'l10n/app_localizations.dart';

/// Regions whose Spanish is Latin American rather than Castilian.
/// '419' is what the OS reports for "Spanish (Latin America)".
const _latamEs = <String>{
  '419', 'MX', 'AR', 'BO', 'CL', 'CO', 'CR', 'CU', 'DO', 'EC', 'GT',
  'HN', 'NI', 'PA', 'PE', 'PR', 'PY', 'SV', 'US', 'UY', 'VE',
};

Locale? resolveAppLocale(List<Locale>? locales, Iterable<Locale> supported) {
  // null/empty means the platform hasn't reported yet — let Flutter decide.
  if (locales == null || locales.isEmpty) return null;

  final preferred = <Locale>[];
  for (final locale in locales) {
    if (locale.languageCode == 'es' && _latamEs.contains(locale.countryCode)) {
      preferred.add(const Locale('es', 'MX')); // an ARB we actually ship
    }
    preferred.add(locale);
  }
  return basicLocaleListResolution(preferred, supported);
}

Wire it up — and note it takes priority over localeResolutionCallback, which only ever sees one locale and should be considered legacy:

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: const [
    Locale('en'),
    Locale('es', 'MX'),
    Locale('es'),
    Locale('es', 'ES'),
    Locale('pt'),
    Locale('pt', 'PT'),
  ],
  localeListResolutionCallback: resolveAppLocale,
  home: const HomePage(),
);

Delegating to basicLocaleListResolution rather than returning a locale yourself matters: it keeps the standard fallback chain intact for the 90% of devices that need no special handling.

Don't forget the iOS bundle

Flutter handles the strings, but the Xcode project has its own list. Open ios/Runner.xcodeproj, select the Runner project, go to Info → Localizations, and add every language and region you ship. Xcode writes empty .strings files and updates project.pbxproj; the App Store reads that to display your supported languages.

Checklist

  1. Dump platformDispatcher.locales on a real device — confirm whether you're seeing es_MX or es_419.
  2. Put your default language at supportedLocales.first.
  3. Put your best regional variant before the bare language code, so it becomes the language-level catch-all.
  4. Name base files the way Flutter does: pt = Brazilian, en = US.
  5. Keep regional ARBs to overrides only.
  6. On stable 3.44.x, filter inherited keys out of untranslated_messages.txt before failing CI.

Manage the variants without hand-editing JSON

Once you're running es, es_MX, es_ES, pt, pt_PT and en_GB, keeping override files honest by hand gets old fast — it's easy to paste a whole base file into a regional ARB and quietly destroy the inheritance you just set up. FlutterLocalisation's ARB editor lets you edit app_<locale>.arb files side by side across every locale in a UI instead of raw JSON, and its ICU plural-syntax validation flags locales missing a plural category the language actually needs.

Try FlutterLocalisation free — then keep reading: every locale Flutter supports, the complete l10n.yaml guide, and failing CI on missing translations.