← Back to Blog

Fix Flutter Untranslated en_US False Positives

flutterl10ngen-l10ni18nci

Fix Flutter Untranslated en_US False Positives

You wired untranslated-messages-file into CI to catch missing translations before they ship. Then you added app_en_US.arb with two regional overrides — colorcolour stays in en_GB, dollar formatting in en_US — and suddenly CI is red with forty untranslated keys for en_US. Keys you never wanted to duplicate. Keys that already render perfectly in the app because en_US inherits them from en.

This is the flutter untranslated-messages-file false positive that bites every team the moment they support region-specific locales. Here's exactly why gen-l10n does it, and a copy-paste Dart script plus l10n.yaml pattern that makes CI fail only on genuinely missing translations.

Why gen-l10n reports inherited keys as untranslated

When you run flutter gen-l10n, the tool generates one Dart class per locale. A region variant extends its base language:

// Generated by gen-l10n
class AppLocalizationsEn extends AppLocalizations { ... }
class AppLocalizationsEnUs extends AppLocalizationsEn { ... } // inherits en

So AppLocalizationsEnUs genuinely inherits every getter it doesn't override. If app_en_US.arb omits welcomeMessage, the runtime happily returns the en value. The generated code is correct. Nothing is missing at runtime.

The problem is that untranslated-messages-file is computed a different way. gen-l10n treats your template ARB (app_en.arb) as the master key list, then reports, for every locale, which template keys are absent from that locale's own ARB file — regardless of the inheritance chain. Since a minimal app_en_US.arb deliberately contains only its overrides, every other template key lands in the report:

{
  "en_US": ["welcomeMessage", "settingsTitle", "logoutLabel", "...36 more"],
  "es": ["checkoutButton"],
  "pt_BR": ["privacyNotice"]
}

That en_US list is all false positives — those keys resolve through en. But es missing checkoutButton is a real gap: Spanish will silently fall back to the English template string. Same JSON, two completely different meanings. This is tracked upstream in flutter/flutter #176020 and the older #67138; a proposed opt-in like exclude-inherited-keys-from-untranslated has been discussed but not shipped, so today you filter the report yourself.

The rule that separates real gaps from inheritance

The insight that makes filtering reliable: a reported (locale, key) pair is an intentional inheritance when the locale has a region suffix and the key exists in its base-language ARB.

  • en_US missing welcomeMessage, but app_en.arb has it → inherited from enignore.
  • es_419 missing checkoutButton, and app_es.arb also lacks it → falls all the way back to the template engenuine gap, fail CI.
  • es (a base language, no region) missing checkoutButtongenuine gap, fail CI.
  • pt_BR with no app_pt.arb at all → nothing to inherit from except the template → every reported key is a genuine gap.

That single rule handles en_US/en_GB, es_419, and pt_BR correctly without a hand-maintained key list.

Step 1 — point the report at a build path

Keep your l10n.yaml minimal and send the report somewhere CI can read it:

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

The file is written as JSON (a map of locale → list of message names) even though older docs sometimes show a .txt name. Running flutter gen-l10n regenerates it every build.

Step 2 — the copy-paste filter script

Drop this in tool/check_untranslated.dart. It stays inside the Flutter toolchain — no Python or extra CI deps — and exits non-zero only on genuine gaps.

// tool/check_untranslated.dart
// Fails only on GENUINELY missing translations, ignoring keys a region
// locale (en_US) deliberately inherits from its base language (en).
//
// Run after `flutter gen-l10n`:  dart run tool/check_untranslated.dart
import 'dart:convert';
import 'dart:io';

const arbDir = 'lib/l10n';
const reportFile = 'build/untranslated_messages.json';

/// Keys allowed to fall back to the template even for a full language
/// locale — e.g. a brand name you keep in English on purpose.
const allowlist = <String, Set<String>>{
  // 'es': {'appTagline'},
};

Set<String> arbKeys(String locale) {
  final file = File('$arbDir/app_$locale.arb');
  if (!file.existsSync()) return {};
  final map = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
  return map.keys.where((k) => !k.startsWith('@')).toSet();
}

String baseLanguageOf(String locale) => locale.split('_').first;

void main() {
  final report = File(reportFile);
  if (!report.existsSync()) {
    print('No report at $reportFile — did you run `flutter gen-l10n`?');
    exit(0);
  }

  final data = jsonDecode(report.readAsStringSync()) as Map<String, dynamic>;
  final genuine = <String, List<String>>{};

  data.forEach((locale, keys) {
    final base = baseLanguageOf(locale);
    final baseKeys = base == locale ? <String>{} : arbKeys(base);
    final allowed = allowlist[locale] ?? const <String>{};

    for (final key in (keys as List).cast<String>()) {
      // False positive: region variant inheriting from its base language.
      if (base != locale && baseKeys.contains(key)) continue;
      // Explicitly whitelisted intentional fallback.
      if (allowed.contains(key)) continue;
      genuine.putIfAbsent(locale, () => []).add(key);
    }
  });

  if (genuine.isEmpty) {
    print('✅ No genuine missing translations (inherited keys ignored).');
    exit(0);
  }

  stderr.writeln('❌ Genuine missing translations:');
  genuine.forEach((locale, keys) =>
      stderr.writeln('  $locale: ${keys.join(', ')}'));
  exit(1);
}

The @-prefix filter drops ARB metadata (@welcomeMessage, @@locale) so only real message keys are compared. The allowlist covers the rare case where you want a full language to fall back to the template — a brand tagline you keep in English — without silencing real gaps.

Step 3 — wire it into CI

Regenerate, then check. In GitHub Actions:

- run: flutter gen-l10n
- run: dart run tool/check_untranslated.dart

Now en_US and en_GB override-only files never trip the build, es_419 inheriting from es is quiet, but a Spanish string nobody translated turns CI red — which is the whole point. For the strict version that also fails on template drift and duplicate keys, see Fail Flutter CI on Missing Translations, and for every l10n.yaml knob, the complete configuration guide.

Keep the ARB files that feed the report clean

This script is only as trustworthy as your ARB files. A misspelled key in app_es.arb reads as a missing translation; a dropped ICU plural category is a runtime break the report won't catch at all. The FlutterLocalisation ARB editor edits app_<locale>.arb files in a UI instead of raw JSON, manages every locale side by side, and its ICU plural-syntax validation flags a locale missing a plural category the language actually needs — like a dropped few/many for Polish, Russian, or Arabic — before it ever reaches CI.

Try FlutterLocalisation free

Stop chasing false positives and silent fallbacks. Try FlutterLocalisation free to manage your ARB files, catch real translation gaps, and validate plural rules across every locale.