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:
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:
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:
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.
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:
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:
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:
The repair itself is four lines:
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:
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:
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:
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:
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:
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:
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:
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.