← Back to Blog

Delete Unused ARB Keys in Flutter gen-l10n Across Locales

flutterarbgen-l10nlocalizationcicleanup

Delete Unused ARB Keys in Flutter gen-l10n Across Locales

A Flutter app that has shipped for a few years usually carries an app_en.arb with hundreds of keys that no widget reads any more. The onboarding flow was rewritten, the old settings screen was deleted, the A/B test lost, but the strings stayed. Every one of those keys is still copied into app_de.arb, app_fr.arb, app_ja.arb, and every one still gets sent to translators when a new locale is added.

flutter gen-l10n will never tell you about this. It reports missing translations, not unused ones. This post shows how to find dead keys with real static analysis, delete them from every locale file without leaving orphan @key metadata behind, and add a CI step so they stop coming back.

Why grep is the wrong tool

The obvious approach is a loop over the template keys with grep -r over lib/. It fails in both directions:

  • False negatives. A key called title, save, or email matches thousands of unrelated lines, so it never looks unused.
  • False positives. A key read through an alias (final l10n = AppLocalizations.of(context)!; l10n.checkoutTotal) or a tear-off passed to a widget is only visible if you follow the Dart types, not the text.

The fix is to resolve the generated AppLocalizations class with the Dart analyzer and count a key as used only when a reference actually points at its generated getter or method. Two packages do this.

Option 1: sweeper (analyzer-based, deletes for you)

sweeper is the newer of the two and is built around the analyzer. Its README describes "resolved static analysis, not name heuristics": a key is reported unused only when no resolved reference to its getter or method exists on your generated localizations class or a locale subclass. Aliased variables and tear-offs are handled. Version 0.5.0 requires Dart 3.13 or later.

It needs no config of its own. It reads arb-dir, template-arb-file, output-class, and output-dir from your existing l10n.yaml:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n/generated
synthetic-package: false
untranslated-messages-file: build/untranslated.json

If you are on Flutter 3.32 or newer, generated code already lands under lib/ rather than the synthetic package:flutter_gen, which is what a static analyser wants. On older projects set synthetic-package: false as above before running any of this.

Install and run:

dart pub add --dev sweeper

# list dead keys, exit 1 if any exist
dart run sweeper check

# preview what clean would remove
dart run sweeper clean --dry-run

# delete them from every ARB file in arb-dir
dart run sweeper clean

The important part for a multi-locale repo is what clean touches. It deletes each unused key and its @key metadata from every ARB file in arb-dir, preserving key order and formatting, and leaves untouched files byte-identical. So you get the cascade delete for free: no stray "@oldWelcome" block left in app_pl.arb to confuse the next person who opens it.

Two flags matter in practice:

  • --keep 'dynamicGreeting*,languageName' protects keys you access dynamically at runtime, for example a switch that picks a key by name. Static analysis cannot see those, so you must list them, and globs are supported.
  • --scan ../feature_checkout adds extra package roots in a monorepo without pub workspaces. Pub workspaces are detected automatically.

There is also dart run sweeper sort to alphabetise keys while keeping metadata attached, which makes diffs of the cleanup reviewable.

Option 2: l10nization_cli (report only)

l10nization_cli is older (2.3.0, published about two years ago) and installs globally:

dart pub global activate l10nization_cli
l10nization check-unused .

It also uses static analysis and its README lists the shapes it recognises: context.l10n.hello, final l10n = AppLocalizations.of(context); l10n.a, AppLocalizations passed as a constructor or function parameter, extension methods on AppLocalizations, and parameterised calls like l10n.a(b).

The difference: it reports, it does not delete. If you use it, you need to do the cascade yourself.

Cascading a delete by hand without orphan metadata

Whether you got the list from l10nization check-unused or from a reviewer, the rule is the same: delete the key and its @key entry from the template, then from every other app_*.arb. Doing that in an editor across twelve locales is where orphan @metadata comes from. A short Dart script is safer:

// tool/remove_arb_keys.dart
// Usage: dart run tool/remove_arb_keys.dart oldWelcome legacyCtaLabel
import 'dart:convert';
import 'dart:io';

void main(List<String> keys) {
  final dir = Directory('lib/l10n');
  final files = dir
      .listSync()
      .whereType<File>()
      .where((f) => f.path.endsWith('.arb'));

  for (final file in files) {
    final map = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
    var removed = 0;
    for (final key in keys) {
      if (map.remove(key) != null) removed++;
      map.remove('@$key');
    }
    if (removed == 0) continue;
    const encoder = JsonEncoder.withIndent('  ');
    file.writeAsStringSync('${encoder.convert(map)}\n');
    stdout.writeln('${file.path}: removed $removed key(s)');
  }
}

Dart's Map keeps insertion order, so the file keeps its existing order. Run flutter gen-l10n afterwards. If the build now fails with "The getter 'oldWelcome' isn't defined", the key was not dead and you have found a gap in your analysis or a dynamic usage to add to --keep.

Wire it into CI next to the missing-translation check

Most teams already fail CI on missing translations by reading the untranslated-messages-file that gen-l10n writes. Its format is {"locale": ["key1", "key2"]}. Put the unused-key check right beside it so one job owns ARB hygiene:

# .github/workflows/l10n.yml
name: l10n
on: [pull_request]

jobs:
  arb-hygiene:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
      - run: flutter pub get

      - name: Generate localizations
        run: flutter gen-l10n

      - name: Fail on missing translations
        run: |
          count=$(jq '[.[] | length] | add // 0' build/untranslated.json)
          if [ "$count" -gt 0 ]; then
            echo "::error::$count untranslated messages"
            cat build/untranslated.json
            exit 1
          fi

      - name: Fail on unused translations
        run: dart run sweeper check --keep 'dynamicGreeting*'

sweeper check exits 0 when clean, 1 when unused keys exist, and 2 on a config or environment error, so no wrapper is needed. Add --json if you want to post the list as a PR comment.

A note on ordering: run flutter gen-l10n before the unused check. The analyser needs the generated AppLocalizations class on disk to resolve references against it.

Doing the first cleanup safely

On a codebase that has never been swept, the first check can return a long list. A workable sequence:

  1. Run dart run sweeper check --json > unused.json and skim it for anything that looks like it is picked by name at runtime. Add those to --keep.
  2. Run dart run sweeper clean --dry-run and read the diff summary.
  3. Run dart run sweeper clean, then flutter gen-l10n, then flutter analyze. Any compile error is a false positive you now understand.
  4. Commit the ARB changes in their own PR so translators and reviewers see only deletions.

Once the baseline is clean, the CI step keeps it that way, and every new locale you add starts from a template that contains only strings the app actually renders.

Keeping the ARB files readable after the sweep

After a few hundred deletions across a dozen files, it is worth opening the result in something that understands ARB rather than raw JSON. The FlutterLocalisation ARB editor shows each key with its translations side by side, so a reviewer can confirm the surviving keys still have every locale filled, and its ICU plural validation flags locales that dropped a few or many form, which a key deletion script will never notice. Plans, including the free tier, are on the pricing page, and the rest of the Flutter i18n tutorials cover the missing-translation side of the same pipeline.

Try FlutterLocalisation free and keep the keys you kept in good shape.