← Back to Blog

Fix Flutter es-419: Stop Serving Castilian to Latin America

flutteri18nl10nes-419locale-resolutionarb

Fix Flutter es-419: Stop Serving Castilian to Latin America

You added app_es.arb, your translator was from Madrid, and now your reviews in Mexico City and Buenos Aires say the app sounds foreign: coger where users expect tomar or agarrar, vosotros verb forms nobody in Latin America uses, ordenador instead of computadora. So you add app_es_419.arb for Latin American Spanish — and nothing changes. An es-MX device still gets the Castilian file.

That's not a bug in your ARB files. It's how Flutter's default locale resolution works, and this post shows the fix: the exact gen-l10n setup for es-419, a copy-paste localeListResolutionCallback that builds the es_MX → es_419 → es → en chain, and how to keep the two Spanish files from drifting apart.

Why es_MX silently matches plain es

Flutter resolves the device's preferred locales against your supportedLocales with basicLocaleListResolution. Its priority order is:

  1. Perfect match (language + script + country)
  2. Language + script match
  3. Language + country match
  4. Language-only match
  5. Country-only match, then the first entry in supportedLocales

The docs are explicit that the algorithm does not take language distance into account — how similar two locales are to each other plays no role. So when an es-MX device meets supportedLocales: [Locale('en'), Locale('es'), Locale('es', '419')]:

  • Perfect match? No es_MX entry.
  • Language + country? es_419's country code is 419, not MX — no match.
  • Language-only? es matches. Resolved: Castilian.

Nothing in the algorithm knows that 419 is the UN M.49 region code for Latin America and the Caribbean and therefore a far better home for es_MX than Spain's Spanish. This is the same blind spot that sends Traditional Chinese users to Simplified Chinese, which we covered in Fix Flutter Showing the Wrong Chinese: zh-Hans vs zh-Hant — there the missing dimension is script, here it's region distance.

The pain of enumerating every regional variant by hand is exactly what flutter/flutter#137147 is about: to make Spanish behave, you'd otherwise have to list all twenty-odd Spanish country locales yourself. You don't — one callback fixes it.

Step 1: Add app_es_419.arb the way gen-l10n expects

gen-l10n derives the locale from the file name, so the setup is just three files in your arb-dir:

lib/l10n/
  app_en.arb        ← template
  app_es.arb        ← Castilian (base Spanish)
  app_es_419.arb    ← Latin American Spanish overrides

A minimal l10n.yaml:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
untranslated-messages-file: untranslated.json

In app_es_419.arb, set @@locale and override only what actually differs:

{
  "@@locale": "es_419",
  "pickUpOrder": "Recoge tu pedido",
  "computerLabel": "Computadora",
  "greetingAll": "Ustedes tienen {count} mensajes nuevos"
}

Run flutter gen-l10n and the generator produces an AppLocalizationsEs419 class that extends AppLocalizationsEs, and adds Locale('es', '419') to the generated supportedLocales. That inheritance is the key maintenance property: any message you don't override in app_es_419.arb falls through to the Castilian string automatically.

The Material and Cupertino widget translations are already covered — flutter_localizations ships an es_419 delegate among its supported languages, so date pickers and dialogs follow along.

Step 2: The localeListResolutionCallback that builds es_MX → es_419 → es → en

The generated supportedLocales alone still resolves es-MX to plain es, for the reason above. localeListResolutionCallback lets you replace the default algorithm entirely — it receives the device's full preferred-locale list and your supported locales, and whatever it returns wins. Here is the copy-paste version:

import 'package:flutter/material.dart';

/// Spanish-speaking regions that should resolve to es-419
/// (Latin America and the Caribbean) instead of Castilian es.
const Set<String> _latamSpanishCountries = {
  '419', 'AR', 'BO', 'CL', 'CO', 'CR', 'CU', 'DO', 'EC', 'GT',
  'HN', 'MX', 'NI', 'PA', 'PE', 'PR', 'PY', 'SV', 'US', 'UY', 'VE',
};

Locale spanishAwareResolution(
  List<Locale>? deviceLocales,
  Iterable<Locale> supportedLocales,
) {
  final supported = supportedLocales.toList();

  for (final device in deviceLocales ?? const <Locale>[]) {
    // 1. Exact language+country match — es_ES users keep Castilian.
    for (final s in supported) {
      if (s.languageCode == device.languageCode &&
          s.countryCode == device.countryCode) {
        return s;
      }
    }

    // 2. Latin American Spanish devices → es_419 before plain es.
    if (device.languageCode == 'es' &&
        _latamSpanishCountries.contains(device.countryCode)) {
      for (final s in supported) {
        if (s.languageCode == 'es' && s.countryCode == '419') {
          return s;
        }
      }
    }

    // 3. Plain language match (es with no region, fr, de, ...).
    for (final s in supported) {
      if (s.languageCode == device.languageCode && s.countryCode == null) {
        return s;
      }
    }
  }

  // 4. Final fallback.
  return const Locale('en');
}

Wire it into MaterialApp:

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  localeListResolutionCallback: spanishAwareResolution,
  // ...
);

Walk the chain for an es-MX device: step 1 finds no exact es_MX entry, step 2 returns Locale('es', '419'), and any message missing from app_es_419.arb inherits from es via the generated class hierarchy. A device set to Spanish with no region (or es-ES) still gets Castilian from step 1 or 3, and a French device with Spanish as its second preference resolves correctly because the callback walks the whole device list, not just the first entry. That last part is why you want localeListResolutionCallback and not the single-locale localeResolutionCallback.

Note the deliberate inclusion of US — an es-US device is a Latin American Spanish speaker, not a Castilian one.

Test it before you ship it

The callback is a pure function, so pin the chain down in a unit test:

test('es-MX resolves to es-419, not Castilian es', () {
  final resolved = spanishAwareResolution(
    [const Locale('es', 'MX')],
    const [Locale('en'), Locale('es'), Locale('es', '419')],
  );
  expect(resolved, const Locale('es', '419'));
});

On a device, switch the system language to Español (México) — resolution reruns whenever the user edits their preferred-language list.

Step 3: Keep es and es-419 from drifting apart

The override-only pattern is efficient, but it has a failure mode: a new feature adds twelve keys to app_es.arb, nobody touches app_es_419.arb, and Mexican users quietly get Castilian for every new screen. Three habits prevent that:

  • Keep untranslated-messages-file on. After every flutter gen-l10n, untranslated.json lists which keys each locale is missing — diff it in CI and fail the build if es_419 falls more than a deliberate margin behind.
  • Only override real differences. If a string is identical in both variants ("Cancelar"), leave it out of app_es_419.arb entirely. Fewer overrides means fewer things to drift.
  • Review both variants side by side. Hand-diffing two JSON files across hundreds of keys is where drift actually happens. The FlutterLocalisation ARB editor manages your app_<locale>.arb files in one UI, so es and es-419 sit next to each other per key instead of in separate raw files — and its ICU plural validation flags a variant that dropped a plural category the language needs.

For the broader fallback picture beyond Spanish, see Flutter Localization Fallback Strategies.

The short version

Flutter's default resolution matches es_MX to plain es because nothing in basicLocaleListResolution knows that region 419 exists. Ship app_es_419.arb with only the strings that differ, drop in the spanishAwareResolution callback above, and lock the es_MX → es_419 → es → en chain in with a unit test. Your Spain users keep vosotros; your Latin American users stop seeing coger in a shopping app.

Managing two Spanish variants (plus everything else) in raw JSON is exactly the drift trap — try FlutterLocalisation free and edit your ARB variants side by side instead.