names.legal ships in six languages: English, French,
German, Italian, Spanish, Portuguese. English is the source language — the
msgid strings in the code are English sentences, and LANGUAGE_CODE = "en".
Which makes locale/en/LC_MESSAGES/django.po a strange file. It exists, Django
compiles it, gettext consults it — and it must not translate anything. Its only
correct content is msgstr == msgid, every entry, forever.
makemessages does not know that. And on 27 July 2026 we found out what it had
been doing about it:
« Thursday » → « Thurgau »
« Saturday » → « Featured »
« End » → « and »
« API keys » → « API access »
2 327 entries like that.
Why a source locale exists at all
The reflex is to delete it. If English translates nothing, why keep a catalogue?
Because i18n_patterns and the language switcher need en to be a language
like the others. /en/pricing/ has to resolve, {% get_available_languages %}
has to list it, activate('en') has to do something coherent, and
modeltranslation is configured with English in the tuple:
MODELTRANSLATION_LANGUAGES = ("en", "fr", "de", "it", "es", "pt")
MODELTRANSLATION_FALLBACK_LANGUAGES = {
'default': ('en',),
'fr': ('en',), # Si français manque → afficher anglais
...
}
English is the fallback for all five others. It is the most load-bearing locale in the project, and the one whose file nobody ever opens, because there is nothing to translate in it.
That last clause is the whole vulnerability.
What makemessages does, and why it is right to do it
When makemessages finds a msgid that is not in the catalogue, it does not
simply add an empty entry. It looks for an existing entry with a similar
msgid, copies that entry’s msgstr, and marks the result fuzzy.
In a translated locale this is a genuine kindness. You change "Save changes"
to "Save your changes", and instead of losing the French, gettext hands the
translator "Enregistrer les modifications" pre-filled and flagged for review.
That is exactly what you want; it is why the feature exists.
In the source locale it is nonsense generation. There is no translation to
preserve — every msgstr is a copy of its own msgid — so “similar existing
entry” means “some other English string that shares letters with this one”.
"Thursday" is textually close to "Thurgau", a Swiss canton that appears in
this codebase because company registrations have a canton. gettext has no way
to know that one is a weekday and the other is a place. It matches, it copies,
it flags.
Run that over a codebase with a few thousand strings and you get two thousand three hundred of them.
Why nobody noticed
A fuzzy entry is ignored at runtime. gettext falls through to the msgid, the
page shows Thursday, and everything looks correct — because everything is
correct, in the only sense that matters to a user.
So this is not a bug. It is a landmine:
"""
2327 entrées de cette nature. Elles ne s'affichaient pas — une entrée fuzzy est
ignorée à l'exécution — mais c'est une mine : le jour où un outil retire les
drapeaux fuzzy en masse, l'anglais affiche « Thurgau » pour « Thursday ». Et
une entrée avait déjà perdu son drapeau en route : ``Pending`` sortait
« Sending... » sur quatre écrans réels.
"""
The day a tool strips fuzzy flags in bulk, English shows “Thurgau” for
“Thursday”. And one entry had already lost its flag on the way: Pending was
coming out as “Sending…” on four real screens.
That last sentence is the reason the whole thing got investigated. Someone
reported that a status badge said Sending… where it should say Pending. One
entry, one missing flag. The other 2 326 were sitting in the same file waiting
for any of the ordinary events that clear fuzzy flags in bulk — a translation
editor’s “accept all”, a msgattrib --clear-fuzzy, a merge tool, a
well-meaning script.
The failure mode of this class of bug is worth naming precisely: the damage is already done, and the trigger is somebody else’s routine action, later.
Cleaning by hand does not work
The obvious repair — open the file, fix 2 327 entries, commit — is the one thing
that definitely fails. The next makemessages rebuilds the same pile, because
the behaviour that produced it is not a mistake to be corrected but a documented
feature running against an input it was never meant for.
Anything that has to be redone after every makemessages has to be a command.
"""
Le nettoyage à la main ne tient pas : la prochaine passe de ``makemessages``
recrée le même tas. D'où une commande, à enchaîner systématiquement après
``makemessages`` — et un test (``core.tests.SourceLocaleTests``) qui échoue si
on l'oublie.
python manage.py makemessages -a
python manage.py sync_source_locale
python manage.py compilemessages
"""
Three lines, in that order, and the middle one is not optional.
The command
The detection is a definition, not a heuristic. An entry in the source locale is
wrong if it is flagged fuzzy, or if its msgstr is anything other than its own
msgid:
def desynchronised(po):
"""Entrées dont le msgstr ne dit pas exactement ce que dit le msgid."""
out = []
for entry in po:
if entry.obsolete:
continue
if "fuzzy" in entry.flags or entry.msgstr != _expected(entry)[0] or (
entry.msgid_plural and entry.msgstr_plural != _expected(entry)[1]):
out.append(entry)
return out
Three cases fall out of that one condition, and they are genuinely different
diseases with one cure: the wrongly matched entry (Thursday → Thurgau),
the correct but flagged entry (right text, ignored at runtime), and the
never filled entry (empty msgstr, added by a makemessages that found
nothing similar).
entry.obsolete is skipped because obsolete entries — the #~ block at the end
of a .po — are gettext’s own archive of strings no longer in the code.
Rewriting them would create noise in every diff and correct nothing.
Plurals need their own answer, and the comment explains the one decision in it:
def _expected(entry):
"""(msgstr, msgstr_plural) attendus pour une entrée de la locale source."""
if not entry.msgid_plural:
return entry.msgid, {}
# Le nombre de formes vient de l'en-tête de la locale : `en` en déclare 2,
# mais on suit ce que le fichier annonce plutôt qu'un 2 codé en dur.
forms = len(entry.msgstr_plural) or 2
plural = {i: (entry.msgid if i == 0 else entry.msgid_plural) for i in range(forms)}
return "", plural
Form 0 is the singular msgid, every other form is the msgid_plural. English
declares two forms; the code reads how many the file actually has and only falls
back to 2 when there are none. The alternative — hard-coding 2 — works
perfectly until the day the command is pointed at a source locale that is not
English, and then produces a catalogue that compilemessages rejects.
That is what --locale is for, and why the default is a default rather than a
constant:
parser.add_argument(
"--locale", default="en",
help="Code de la locale source (défaut : en).")
The repair itself is four lines:
def synchronise(entry):
msgstr, plural = _expected(entry)
if entry.msgid_plural:
entry.msgstr_plural = plural
else:
entry.msgstr = msgstr
entry.flags = [f for f in entry.flags if f != "fuzzy"]
Note that it strips fuzzy by filtering rather than assigning a fresh list.
Other flags — python-format, no-python-format, c-format — carry meaning
that compilemessages enforces, and clearing them wholesale would break the
build in a way that is very hard to attribute back to this function. The next
section is about exactly that.
The check mode, and the test that uses it
The command has a second personality:
parser.add_argument(
"--check", action="store_true",
help="N'écrit rien ; sort en erreur si le fichier est désynchronisé.")
if options["check"]:
if drifted:
self.stdout.write(self.style.ERROR(
f"{locale} : {len(drifted)} entrée(s) désynchronisée(s). "
f"Lancer « manage.py sync_source_locale »."))
for entry in drifted[:10]:
self.stdout.write(f" {entry.msgid!r} -> {entry.msgstr!r}")
if len(drifted) > 10:
self.stdout.write(f" … et {len(drifted) - 10} autres")
raise CommandError("locale source désynchronisée")
Ten examples and a count, not 2 327 lines. The examples are what make the
failure diagnosable in a CI log — 'Thursday' -> 'Thurgau' explains itself, and
a bare number does not.
The same detector then backs a test, which is the part that actually holds:
class SourceLocaleTests(SimpleTestCase):
"""...
Le nettoyage se refait avec `manage.py sync_source_locale`. Ce test est là
pour qu'on ne l'oublie pas — un oubli n'a AUCUN symptôme jusqu'au jour où
un drapeau saute.
"""
def test_the_source_locale_says_exactly_what_its_msgids_say(self):
path = source_locale_path("en")
self.assertTrue(path.exists(), f"locale source absente : {path}")
drifted = desynchronised(polib.pofile(str(path)))
details = "\n".join(
f" {e.msgid!r} -> {e.msgstr!r}" for e in drifted[:10])
self.assertEqual(
drifted, [],
f"{len(drifted)} entrée(s) de locale/en ne disent pas ce que dit leur "
f"msgid. Lancer « manage.py sync_source_locale ».\n{details}")
An omission has NO symptom until the day a flag drops. A convention that depends on remembering a step is not a convention; it is a countdown. The test converts “we agreed to run the command” into “the build fails if you did not”.
A guard that cannot fail guards nothing
The second test in that class is the one I would point at if I had to defend this whole file:
def test_the_detector_actually_catches_a_drift(self):
"""Un garde-fou qui ne peut pas échouer ne garde rien : on lui montre
les trois formes de dérive déjà observées en vrai."""
po = polib.POFile()
po.append(polib.POEntry(msgid="Pending", msgstr="Pending")) # sain
po.append(polib.POEntry(msgid="Thursday", msgstr="Thurgau")) # apparié à tort
po.append(polib.POEntry(msgid="Saturday", msgstr="Saturday",
flags=["fuzzy"])) # bon texte, mais ignoré
po.append(polib.POEntry(msgid="API key", msgstr="")) # jamais rempli
drifted = {e.msgid for e in desynchronised(po)}
self.assertEqual(drifted, {"Thursday", "Saturday", "API key"})
The first test passes on a clean repository. It will keep passing if someone
refactors desynchronised into return []. Every assertion of the form this
file has no problems has that shape, and it is the standard way a guard rots:
it goes green, stays green, and stops meaning anything.
So the detector is shown four hand-built entries — one healthy, three sick, each
sickness one that was actually observed in the real file — and asked to name the
sick ones. The set comparison catches both directions: a detector that stops
seeing empty msgstr fails, and so does one that starts flagging the healthy
entry.
The comments naming each case are doing real work too. # bon texte, mais
ignoré is the case a reader would otherwise delete as redundant — Saturday
maps to Saturday, what could be wrong? — and the answer, that a fuzzy flag
makes a correct entry invisible, is the entire subject of the file.
The second bug: a per cent sign that is not a format
The same catalogue has a smaller problem with the same shape, and it is worth the section because the shape is what generalises.
Two source strings contain a literal per cent sign:
LITERAL_PERCENT = [
"e.g. 30% deposit, Balance",
"e.g., 'High demand', '15% growth'",
]
TRANSLATED_LOCALES = ["fr", "de", "it", "es", "pt"]
gettext scans msgids for printf-style conversions to decide whether to stamp
#, python-format. In "30% deposit" it sees % d — per cent, space, d —
and %d with an intervening space is a legal conversion specification. So it
concludes the string is a format string and flags it.
Two things then go wrong, and neither is obvious from the flag:
"""
L'heuristique de gettext lit « % d » (dans « 30% deposit ») comme une
conversion et repose `python-format` à CHAQUE `makemessages`, ce qui
refuzzyfie les entrées — donc les affiche en anglais — et fait échouer
`compilemessages` sur les traductions qui écrivent « 30 % » avec l'espace
typographique.
"""
First, compilemessages validates format strings: a translation whose
conversions do not match its msgid’s is an error, not a warning. French
typography puts a non-breaking space before %, so the correct French
translation is « 30 % d'acompte » — which gettext reads as containing % d',
a different conversion from the one it thinks is in the msgid. The build
fails, on a string that has no formatting in it at all, in a language whose
typographic rules are the proximate cause.
Second, the entry gets re-fuzzied, and a fuzzy entry falls back to English. So the French page shows an English example string, silently.
The fix is a flag — no-python-format — which gettext honours. And then:
"""
Le drapeau `no-python-format` corrige, mais il est effacé à la passe
suivante : constaté deux fois dans la même session.
"""
The no-python-format flag fixes it, but is erased on the next pass: observed
twice in the same session.
Which is the same story as the source locale, one size down. A repair that a routine command undoes is not a repair; it is a chore. And the response is the same — not a cleverer fix, but a tripwire:
def test_the_literal_percent_entries_stay_out_of_python_format(self):
for locale in self.TRANSLATED_LOCALES:
path = source_locale_path(locale)
po = polib.pofile(str(path))
for msgid in self.LITERAL_PERCENT:
entry = po.find(msgid)
if entry is None or entry.obsolete:
continue
self.assertNotIn(
"python-format", entry.flags,
f"{locale} : {msgid!r} remarqué `python-format` par makemessages — "
f"reposer `no-python-format`.")
self.assertNotIn(
"fuzzy", entry.flags,
f"{locale} : {msgid!r} est fuzzy, donc affiché en anglais.")
Five locales × two strings, and the failure message says what to do rather than
what happened. if entry is None or entry.obsolete: continue keeps the test from
failing when the string is legitimately removed from the code — the test guards
a known hazard, and must not become an obstacle to deleting the copy that
carries it.
source_locale_path is reused here for a locale that is not the source, which
is a small naming lie the code lives with: the function builds
locale/<code>/LC_MESSAGES/django.po and the name describes its first caller
rather than its behaviour. Worth renaming; not worth pretending it is fine.
What carries over
- A source locale is a locale, and tooling will treat it like one. If your
msgids are English and English is inLANGUAGES,locale/enis a file that gettext will happily fill with garbage, and nothing will display wrong until something clears the flags. fuzzyis not a warning, it is a hiding place. Damage under a fuzzy flag is invisible in production and invisible in review, because the file nobody reads is the one where every entry is supposed to be trivial.- If a command undoes your fix, the fix is a command. Then chain it, and test that it was run.
- Test the detector, not only the state. Everything is fine is an assertion that passes when the checker breaks; feed it known-bad input taken from the incident.
- Put the remedy in the failure message.
Lancer « manage.py sync_source_locale »is the difference between a red build somebody fixes in a minute and one that gets an issue opened about it.