You add one string to en.json, ship it, and move on.
Three weeks later a French user emails a screenshot: a raw auth.errors.locked
sitting where a translated message should be. fr.json never got the key. Nor
did zh.json. And nobody noticed, because a missing translation doesn't throw —
it just quietly renders the key.
Locale drift is the classic absence bug: the failure isn't an error you can
catch, it's a translation that was never written. Your test suite is green, your
build passes, and the gap only shows up in production, in a language you don't
read.
Why I didn't just use an existing tool
There are i18n tools out there, but every one I reached for had a catch:
-
Framework-locked.
i18next-clionly understands i18next. Most "find missing translations" tools assume one specific library's runtime. - Account required. Locize and friends want you to push your strings to their platform first.
- Heavy. AST-based linters that need config and a plugin per framework, when all I wanted to know was "which keys are missing from which file."
And the deal-breaker: most naive "diff two locale JSONs" scripts get plurals
wrong.
The plural problem (this is the interesting part)
Here's the trap. English has two plural forms: items_one and items_other.
So the obvious rule is "every other locale must have both forms too."
That rule is wrong.
Chinese has one plural category — other. A zh.json with only
items_other is correct. Flagging it as "missing items_one" is a false
positive, and false positives are how a linter ends up ignored.
Meanwhile Russian has four categories (one, few, many, other). A
ru.json that copied English's two-form shape is genuinely broken — and a
parity-with-English check would never catch it, because English doesn't have
few or many to compare against.
So the same items_other-only shape is:
en base: items_one + items_other
zh.json → items_other only ✓ in sync (Chinese needs only `other`)
fr.json → items_other only ✗ missing {one}
ru.json → items_one + _other ✗ missing {few, many}
To get this right you have to resolve the required CLDR plural categories from
the target language, not from the base file. So that's what localediff does:
it carries a conservative table of CLDR cardinal categories, infers each file's
language from its name (fr.json → fr, pt-BR.json → pt), and checks
completeness against that language's rules. Unknown language? It falls back to
parity with the base, so you never get a confidently-wrong answer.
How it works
Point it at a folder. It treats en.json as the base and checks everything else:
npx localediff ./locales
✗ fr.json
missing (1): auth.errors.locked
plural cart.items — has {other}, missing {one}
empty (1): footer.copyright
extra (1): legacy.banner
✓ zh.json — in sync
✗ 1 of 2 file(s) drifted — 1 missing, 1 plural gap(s), 1 empty, 1 extra
Four things it reports:
| Check | What it means |
|---|---|
| missing | a key in the base the target never translated |
| plural | a pluralized key missing a CLDR form the target language needs |
| empty | a key that exists in the target but the value is blank — the slot's there, nobody filled it |
| extra | a leftover key the base dropped (usually after a rename) |
It's structural, so it doesn't care what framework you use — next-intl,
react-intl, i18next, vue-i18n, or a plain JSON catalog all work. Nested objects
flatten to dot-paths (auth.errors.locked); arrays get indexed.
It exits non-zero on drift, so it drops straight into CI:
- run: npx localediff ./locales
exit 0 every checked file is in sync
exit 1 something drifted
exit 2 error (file not found, bad JSON)
There's a --format json for machine output, --lang to force a language when
file names aren't locale codes, and --ignore-plural / --ignore-extra /
etc. when you want to narrow the gate.
Install
It ships on both registries with byte-for-byte identical behavior, because
half the projects I work on are Node and half are Python:
npx localediff ./locales # Node — zero deps, stdlib only
pip install localediff # Python — pure stdlib
Zero dependencies on purpose: no install bloat, nothing to audit, and npx /
pip install is the whole setup.
A few design notes
-
Zero deps means JSON only. Supporting YAML or
.propertieswould mean pulling in a parser, which defeats the point. Convert to JSON, or tell me I'm wrong about the tradeoff. -
_otheris the tell. A key only counts as a plural group if it has an_otherform (the one i18next always requires). That keeps ordinary keys likestep_one/step_twofrom being mistaken for plurals. - Conservative on unknown languages. Better to miss a gap than to invent one. Confidently-wrong i18n linting is worse than no linting.
Try it / break it
Code, issues, and the full README:
It's MIT and tiny. I'd love to know where the plural table is wrong for your
language, or what your locale files do that I didn't think of.
How do you currently catch a translation that silently went missing — CI check,
review checklist, or a user emailing you a screenshot?
Top comments (0)