Originally published on stringlane.app.
I am a Flutter Staff Engineer and I have been building mobile apps for ten years. Over that time I have worked on a dozen or so of them, mostly commercial, with a few of my own. One of mine supports five languages and I only understand two of them.
That was fine until I shipped a release with strings missing from some of those locales, and translations that were wrong where they existed at all. Every missing string rendered in English, and the build that produced them was green. No warning, no red. A user told me, after it shipped.
Everything else in that release ran itself. Codemagic builds the ipa and the aab, ships them to TestFlight and the Play Store internal track, and then emails me that it worked. Localization was the hole. That hole is fairly specific, and if you ship a Flutter app in more than one language you probably have the same one.
Adding one string means opening five files
Here is the whole problem, and it is not clever.
ARB is the translation file format Flutter reads: JSON, with per-key metadata attributes. gen_l10n wants one file per locale. I add a key to the English one:
{
"checkoutButtonLabel": "Continue to checkout"
}
Then I open app_uk.arb and add it. Then app_pl.arb. Then app_de.arb. Then app_es.arb. One string, five files, and nothing in that process checks whether I got all five.
This is the gen_l10n workflow specifically. If the chore is all you want gone, slang replaces it: one file per locale still, but dart run slang analyze reports missing and unused translations, and its migrate command converts ARB files into slang's own JSON, one file at a time. It is the answer this complaint usually gets, and it is a fair one.
After that, the translations. I paste the new strings into an AI, get them back, and paste each one into the right file. Three keys is three passes. Now I have model output in four languages, three of which I do not understand, and nothing has checked any of it.
The build will not save you here
You would expect a missing translation to stop the build. It does not.
Run flutter gen-l10n with a locale that is short a few keys and you get this, in among the rest of the build output:
"de": 12 untranslated message(s).
To see a detailed report, use the untranslated-messages-file
option in the l10n.yaml file:
untranslated-messages-file: desiredFileName.txt
<other option>: <other selection>
This will generate a JSON format file containing all messages that
need to be translated.
It exits successfully and the build continues.
The usual version of this complaint is wrong, so it is worth being precise. Flutter reports the gap. It prints a count, it tells you how to get the detailed version, and if you take that advice it writes a JSON report listing every missing key per locale. The information is there.
Two details about how it reaches you. The count goes out through logger.printStatus, not printWarning and not printError, so it arrives at the same level as every other line in the build. And that call sits in an else branch: set untranslated-messages-file and the console count stops printing entirely, replaced by the file.
None of it stops the build. That was the part I had not expected, so I read what the generator does with a message that has no translation for a locale. It is in gen_l10n.dart:
var localeWithFallback = locale;
if (message.messages[locale] == null) {
_addUnimplementedMessage(locale, message.resourceId);
localeWithFallback = _templateArbLocale;
}
The missing German string falls back to the template locale. It renders in English. The app compiles, runs, and looks fine to me, because I read English and I wrote the template.
That is the release I shipped.
A count is not a check
Say you do read the count, and you fix all twelve. You still know nothing about:
- A string that exists and is wrong. Present in the file, so it is not untranslated.
-
A dropped placeholder. A translation comes back without
{count}in it at all. The method still takes the parameter, the string never uses it, and gen_l10n does not report it. Rename it instead,{count}to{Anzahl}, and you get this: gen_l10n infers an undeclared placeholder, generates a method taking both names, and your call sites stop compiling. -
Missing plural forms. English has two categories,
oneandother. Ukrainian has four, andoneis not the one you would guess: it takes 21 and 31, but not 11. Hand a model an English plural and you get back two forms. The key is present, the count is satisfied, and every number that neededfewormanyfalls through to the singleotherstring.
So the count answers "did someone type something here." It does not answer "is this correct."
Some strings are not in ARB at all. Your app's display name and every iOS permission prompt come from ios/Runner/Info.plist, and localizing them means a file you create by hand: ios/Runner/<lang>.lproj/InfoPlist.strings, which is Apple's long-documented mechanism for localizing Info.plist keys. gen_l10n never opens it. So you can have a perfect ARB report and still ship a camera permission dialog in English.
Three things you can do today without any new tool
These all work. I used the first two for a while.
1. Turn on the report. Flutter told you how, in that block above. In l10n.yaml:
untranslated-messages-file: l10n_missing.json
You get the file instead of the count. Catches absent keys. Catches nothing else. And it takes the console line away, so either you read the file, or you see nothing in the console.
2. Diff the keys yourself. ARB is JSON, so this is short:
import json, pathlib, sys
l10n = pathlib.Path("lib/l10n")
template = json.loads((l10n / "app_en.arb").read_text(encoding="utf-8"))
keys = {k for k in template if not k.startswith("@")}
incomplete = False
for f in sorted(l10n.glob("app_*.arb")):
if f.name == "app_en.arb":
continue
other = json.loads(f.read_text(encoding="utf-8"))
missing = keys - {k for k in other if not k.startswith("@")}
if missing:
incomplete = True
print(f.name, sorted(missing))
sys.exit(1 if incomplete else 0)
Run it before you tag. That encoding="utf-8" is load-bearing on Python 3.14 and earlier. Without it the script dies on the first Ukrainian file on a Windows runner, which took me longer to work out than it should have.
3. Fail the build in CI. Wire the report from step 1 into a check that fails when the file contains anything but {}. Watch that shape. When nothing is missing gen_l10n writes an empty JSON object, not an empty file, so [ -s l10n_missing.json ] is true on every green build and the check fires forever until you give up and delete it. jq -e 'length == 0' l10n_missing.json is the whole check.
And if a new tool is not the problem, dart pub global activate rebellion then rebellion analyze lib/l10n/. It catches missing keys, missing plural values per locale, and a translation that dropped a placeholder the template declares or added one it does not. That covers the placeholder and plural gaps above, and you write none of it. It is ARB only, it is CLI only by its author's own README, and the last release was March 2025.
If you would rather have this in the editor, two extensions cover part of it. Google's ARB Editor validates a file against the ARB schema, understands ICU syntax and offers quick fixes: 107,000 installs, last updated December 2025. i18n-ally shows every locale for a key at once and has passed a million installs, though its repository has had no commits since December 2024. Both work on the file you have open rather than sweeping a project on a tagged build.
The three do not have the same limits. Option 2 only compares key sets. Options 1 and 3 read gen_l10n's own report. And whichever you pick, gen_l10n runs on every build anyway, so malformed ICU fails the build for free.
If you ship ARB only and you understand every language in your app, that is enough. It stops being enough when the values themselves need checking, or when strings start living outside ARB: the InfoPlist.strings above, a marketing site, a web dashboard sharing the same copy.
What I ended up building
I wrote StringLane because localization was the one part of my release I still did by hand.
It opens a project folder and shows every locale side by side. Add a key once and it lands in every locale file. One project is one format, so pointed at a Flutter app it reads ARB. Each other format it handles, .xcstrings, iOS .strings, Android XML, i18next JSON, opens as its own project. The InfoPlist.strings above is a second window rather than the same one.
Most of that checking you can get for free, and the section above shows you how. What you do not get that way is a place to keep working: it is more wiring to keep green, and I did not keep mine green.
Paste a string into a model and it sees the string, nothing else about your product. It does better with what a human translator would have asked for: a product description, branding rules, notes for a particular locale, a character limit per key, terms that must not be touched, and a description of what each key is for. In an ARB project it writes some of that metadata for you.
Translation is bring your own key, or a local model with no key at all. There is no server in the middle.
In practice that means I stopped doing the three things above. StringLane is open while I work, and a new key goes in there rather than in a file. βT translates the active key into every non-base locale, and when a backlog builds up the command palette has Translate all missing with AI, which runs across the project in one pass. Missing keys, broken placeholders, incomplete plurals, ICU errors and a protected term the model rewrote all show up in the table, and βJ opens a panel listing every one of them. I do not run anything or read a log to find out where the translations stand. It is on screen the whole time. Releasing comes down to checking that it is green.
None of that tells you whether a translation is good, which is the other half of what got me. Whether the German is the right German is not something a tool knows, and mine does not know it either. What it removes is the other class of failure, the one where you could have known and did not.
One-time license, $49 early-adopter, 14-day trial, no signup. macOS is signed and notarised. The Windows build is unsigned, so SmartScreen will warn you. Download StringLane.
If you try it, I would genuinely like to hear where it breaks. And feature requests, obviously.

Top comments (0)