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.localesis 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 sendinges_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:
- Exact match on
languageCode_scriptCode_countryCode. - language + script — only attempted if the device locale carries a
scriptCode. - language + country — only attempted if the device locale carries a
countryCode. - 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.
- country only, accumulated across the loop as a last resort. The source comment notes this fallback only really applies on iOS.
supportedLocales.firstif 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 registeredes_null_null. Miss. - Pass 2 skipped (no
scriptCode). - Pass 3 looks for
es_419. Your entry registeredes_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
esstill hitsLocale('es')at pass 1 (exact keyes_null_null), so hoistinges_MXdoesn't hijack it. supportedLocales.firstis 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.arb — there 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
- Dump
platformDispatcher.localeson a real device — confirm whether you're seeinges_MXores_419. - Put your default language at
supportedLocales.first. - Put your best regional variant before the bare language code, so it becomes the language-level catch-all.
- Name base files the way Flutter does:
pt= Brazilian,en= US. - Keep regional ARBs to overrides only.
- On stable 3.44.x, filter inherited keys out of
untranslated_messages.txtbefore 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.