← Back to Blog

Fix gen-l10n Untranslated Messages False Positives

fluttergen-l10narbcii18n

Fix gen-l10n Untranslated Messages False Positives

You added app_en_US.arb with three overridden strings, app_pt_BR.arb with two, and app_zh_Hans.arb with one. Everything renders correctly in the app. Then your CI step reads untranslated_messages.json and prints 400 missing keys.

{
  "en_US": ["signIn", "cartTotal", "itemCount", "...397 more"],
  "pt_BR": ["signIn", "cartTotal", "itemCount", "...398 more"],
  "zh_Hans": ["signIn", "cartTotal", "itemCount", "...399 more"]
}

Nothing is actually missing. Here is what the tool is really telling you, and three ways to get a green build.

Why the report disagrees with the runtime

gen-l10n generates one Dart class per locale, and regional locales are generated as subclasses of the base language class:

// app_localizations_en.dart (generated)
class AppLocalizationsEn extends AppLocalizations {
  AppLocalizationsEn([String locale = 'en']) : super(locale);

  @override
  String get signIn => 'Sign in';
  // ...every key from app_en.arb
}

class AppLocalizationsEnUs extends AppLocalizationsEn {
  AppLocalizationsEnUs() : super('en_US');

  @override
  String get color => 'Color'; // the only key app_en_US.arb overrides
}

So AppLocalizationsEnUs().signIn returns 'Sign in' through plain Dart inheritance. That is why the app is correct. This is also why the tool refuses to run when the base file is absent:

Arb file for a fallback, pt, does not exist, even though
the following locale(s) exist: [pt_BR].
When locales specify a script code or country code, a
base locale (without the script code or country code) should
exist as the fallback. Please create a app_pt.arb file.

The untranslated-messages report, however, was computed per file. Up to and including Flutter 3.44, the collector in gen_l10n.dart read:

_allMessages
    .where((Message message) => message.messages[locale] == null)
    .forEach((Message message) => _addUnimplementedMessage(locale, message.resourceId));

One condition: "is this key absent from this ARB file?" Inheritance was never consulted. Every key you deliberately left out of app_pt_BR.arb so it would inherit from app_pt.arb landed in the report. That is the whole bug, and it is tracked as flutter/flutter#176020 (P2, opened 25 September 2025).

Fix zero: upgrade to Flutter 3.47

That issue is closed. PR #187950 landed on 13 June 2026 and shipped in Flutter 3.47 stable (12 August 2026). The check now looks at the parent locale:

// Only mark a message as unimplemented/untranslated for a regional subclass
// (e.g. en_US) if it is also missing in the parent base locale (e.g. en).
final parentLocale = LocaleInfo.fromString(locale.languageCode);
_allMessages
    .where((Message message) =>
        message.messages[locale] == null && message.messages[parentLocale] == null)
    .forEach((Message message) => _addUnimplementedMessage(locale, message.resourceId));

If you can move to 3.47 or newer, run flutter gen-l10n again and the file collapses to {}. Two caveats before you close the ticket:

  • On 3.47 the parent is the language locale only. app_zh_Hant_TW.arb is checked against app_zh.arb, not against app_zh_Hant.arb, because the generated AppLocalizationsZhHantTw extends AppLocalizationsZh directly. Deeper script-parent chains were tightened later on main.
  • The report is only silenced for inherited keys. A key missing from the base language is still reported, which is exactly what you want CI to catch.

If you are pinned to 3.44 or older, pick one of the next three.

Fix 1: delete the regional ARB entirely

The cheapest fix, and the right one surprisingly often. If app_en_US.arb only ever repeated app_en.arb, it earns nothing. Delete it. You can keep Locale('en', 'US') in supportedLocales, because the generated delegate matches on language code when no exact locale class exists, and lookupAppLocalizations falls through to AppLocalizationsEn.

Same for pt_BR: if Brazilian Portuguese is your only Portuguese, name the file app_pt.arb and stop maintaining a region variant that overrides nothing. Zero files, zero false positives, zero CI logic.

Fix 2: thin override files plus a committed baseline

Keep app_pt_BR.arb for the handful of genuinely different strings, and teach CI that the rest of the report is expected. First, put the report somewhere CI can read (the path is resolved relative to the project directory):

# 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

Then commit a baseline and diff against it:

# regenerate the baseline whenever you intentionally add an inherited key
flutter gen-l10n
jq -S . build/untranslated_messages.json > l10n/untranslated_baseline.json
# ci step
flutter gen-l10n
jq -S . build/untranslated_messages.json > /tmp/report.json
if ! diff -u l10n/untranslated_baseline.json /tmp/report.json; then
  echo "Untranslated-message report changed. Translate the new keys or refresh the baseline."
  exit 1
fi

This fails on new untranslated keys only. The downside is a baseline file someone has to refresh, and the review burden of noticing when a refresh hides a real gap.

Fix 3: check inheritance yourself

Better than a baseline: compute the answer the old tool did not. A key is a real problem only when it is missing from the base-language ARB too. Under 25 lines of bash and jq:

#!/usr/bin/env bash
# tool/check_l10n.sh
set -euo pipefail

ARB_DIR=lib/l10n
REPORT=build/untranslated_messages.json

flutter gen-l10n

missing=$(jq -r 'to_entries[] | .key as $loc | .value[] | "\($loc)\t\(.)"' "$REPORT" \
  | while IFS=$'\t' read -r locale key; do
      lang="${locale%%_*}"
      base="$ARB_DIR/app_$lang.arb"
      # a base-language locale has no parent to inherit from
      if [ "$locale" = "$lang" ] || ! jq -e --arg k "$key" 'has($k)' "$base" > /dev/null; then
        echo "  $locale: $key"
      fi
    done)

if [ -n "$missing" ]; then
  echo "Untranslated keys with no base-language fallback:"
  echo "$missing"
  exit 1
fi

echo "l10n check passed: every reported key is inherited from its base language."

"${locale%%_*}" turns pt_BR, zh_Hans and zh_Hant_TW into pt, zh and zh, which matches how the generated subclasses actually resolve. Prefer Dart? Same rule, runnable with dart run tool/check_l10n.dart:

import 'dart:convert';
import 'dart:io';

void main() {
  final report = jsonDecode(File('build/untranslated_messages.json').readAsStringSync())
      as Map<String, dynamic>;

  final problems = <String>[];
  for (final entry in report.entries) {
    final locale = entry.key;
    final language = locale.split('_').first;
    final baseFile = File('lib/l10n/app_$language.arb');
    final base = locale == language || !baseFile.existsSync()
        ? const <String, dynamic>{}
        : jsonDecode(baseFile.readAsStringSync()) as Map<String, dynamic>;

    for (final key in (entry.value as List).cast<String>()) {
      if (!base.containsKey(key)) problems.add('$locale: $key');
    }
  }

  if (problems.isEmpty) {
    stdout.writeln('l10n check passed.');
    return;
  }
  stderr.writeln('Untranslated keys with no base-language fallback:');
  problems.forEach(stderr.writeln);
  exit(1);
}

Run it after flutter gen-l10n in the same job. It is version-proof: on 3.47+ the report is already clean and the script passes trivially, on older SDKs it filters the noise. Related reading: failing Flutter CI on genuinely missing translations.

Which one to pick

Delete the file if the regional ARB overrides nothing. Use fix 3 if you have real regional overrides, because it encodes the actual rule rather than a snapshot. Reach for the baseline only when you cannot add jq or a Dart step to the pipeline. What you should not do is copy every key from app_en.arb into app_en_US.arb to silence the report: you have just doubled your translation surface, and the next English copy change now has to be made twice.

Keep the ARB files honest

Thin override files are the correct pattern, but they are easy to get wrong by hand: a key typo in app_pt_BR.arb silently falls back to Portuguese instead of failing loudly. FlutterLocalisation's ARB editor gives you a table view across every locale, so you can see at a glance which keys a region file actually overrides instead of reading raw JSON. It also runs ICU plural-syntax validation, which catches the other class of silent l10n bug: a locale missing a plural category its language genuinely needs, like a dropped few in Polish or many in Russian.

Try FlutterLocalisation free and see the pricing when your locale count grows.