I added a drift-history model to my Django app, gave its fields the usual
verbose_names, and ran the one command you always run:
django-admin makemessages -l ja
It found the new strings, dropped them into the .po file, I recompiled, and
moved on. A while later I opened the app in Japanese and a column header read
取得日時 — "fetch time." I never wrote "fetch time." The field is
Detected At. Where did a translation I never typed come from?
makemessages wrote it. And it was confidently, specifically wrong.
What makemessages actually did
When you add a new msgid that looks similar to one you've already translated,
makemessages doesn't leave it blank. It guesses — it copies the nearest
existing translation over and flags it:
#, fuzzy
#| msgid "Fetched At"
msgid "Detected At"
msgstr "取得日時"
Read that carefully. My new string is "Detected At". gettext looked at my old,
already-translated "Fetched At" → 取得日時, decided the two English strings
were close enough, and pre-filled my new label with the old one's Japanese. The
#| msgid "Fetched At" line is it telling me "I copied this from Fetched At."
The #, fuzzy is it telling me "…but I'm not sure."
It did this across the whole batch:
"Detected At" 取得日時 → should be 検出日時 (bled from "Fetched At")
"Drift Snapshot" スナップショット → should be ドリフトスナップショット
"Drift Snapshots" EOLスナップショット一覧 → should be ドリフトスナップショット一覧 (bled from "EOL Snapshots")
Every one is plausible. 取得 (fetch) and 検出 (detect) are one concept apart.
"Snapshot" really is スナップショット — it just dropped the "Drift" qualifier.
"Drift Snapshots" got the translation for "EOL Snapshots" because both end in
"Snapshots." The heuristic matches on the English source text, and UI labels are
short and repetitive, so it matches things that share words but mean different
things.
Why this is worse than an empty translation
An untranslated string is honest — it falls back to the English source, you see
a stray English word in your Japanese page, you go fix it. A fuzzy string is
a liar wearing a suit. There are two ways it bites, and both are quiet:
-
Django's
compilemessagesdrops fuzzy entries by default. So the "safe" outcome is that your brand-new labels silently revert to their Englishmsgidin the middle of a Japanese UI — translation present in the.po, absent in the running app. -
The moment anyone accepts the guess — strips the
#, fuzzyline, runsmsgfmt --use-fuzzy, or lets a translation tool auto-approve — the wrong Japanese ships and looks completely intentional.
And here's the part that made it invisible to me: my app defaults to English
(LANGUAGE_CODE = 'en') and only switches to Japanese from the browser's
Accept-Language via LocaleMiddleware. I review in English. Every reviewer
who reads the diff in English sees a green .po full of confident-looking
Japanese and no reason to doubt it. The only people who see 取得日時 where it
should say 検出日時 are the Japanese users — who assume that's just what the app
calls it.
The fix is a discipline, not a command
There's no clever flag. The fix is to treat #, fuzzy as "unreviewed," never
"translated." After every makemessages:
grep -n "#, fuzzy" locale/ja/LC_MESSAGES/django.po
For each hit, read the #| msgid "…" line — it tells you exactly which old
string the guess bled from — then either correct the msgstr and delete both
comment lines, or clear it and translate from scratch. Only then recompile:
django-admin compilemessages
Same sweep surfaced two neighbors of the same bug: a string with an empty
msgstr ("More actions" → falling back to English) and a msgstr that had
been concatenated onto itself — the same Japanese sentence pasted twice into
one message. Both are the kind of thing you never notice reviewing in your source
language.
Takeaways
-
makemessagesdoesn't leave new strings blank — it guesses from your existing translations and marks the guess#, fuzzy. That's a suggestion, not a translation. - The guess matches on source-text similarity, so short, repetitive UI labels
(
verbose_names especially) are exactly where it's most likely to be wrong — "Detected At" inherits "Fetched At," "Drift Snapshots" inherits "EOL Snapshots." -
compilemessagesignores fuzzy by default, so a fuzzy string either silently reverts to English or, if anyone accepts it, ships a confident mistranslation. Both are quiet failures. - If you review in your source language, you are structurally blind to this. The bug only renders in the target locale.
- Make
grep "#, fuzzy"(and emptymsgstr) part of the ritual — ideally a CI check that fails when either exists. I don't have that yet; the.mois hand-compiled and committed, so a stale one can still drift from the.po.
This is one paper cut from a self-hosted AWS drift detector I build in the open —
Django, bilingual UI, MIT, one docker compose up:
syncvey.com. If you ship a translated app: do you have
anything that stops a #, fuzzy from reaching production, or do you catch the
wrong ones the way I did — a user squinting at a label that isn't quite right?
Top comments (2)
This is such a clean example of gettext's fuzzy mechanism doing exactly what it's designed to do and still biting you. Two things worth adding: the fuzzy flag is gettext saying "machine-guessed, a human must confirm" — the guess is a feature, it saves the translator retyping when "Detected At" really is close to "Fetched At." The part that should have saved you is that msgfmt (and Django's compilemessages) excludes #, fuzzy entries from the compiled .mo by default, so a fuzzy string should fall back to the msgid — your English "Detected At" — not render the old Japanese. If Japanese actually shipped, the flag got stripped somewhere between makemessages and compile: a translation tool that "approved" it, or a manual edit. That's the thing actually worth hunting. The durable fix is a CI check that greps the compiled catalog (or the .po) for surviving fuzzy entries and fails the build — it turns "confidently wrong translation in prod" into a red pipeline.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.