Meta description: Ran makemessages and got stuck with empty msgstr entries? Here's how the PO file format works and how to automate Django translation safely.
You ran python manage.py makemessages, opened locale/fr/LC_MESSAGES/django.po, and got a wall of msgid lines with blank msgstr values. That's the point where Django i18n stops feeling built-in and starts feeling like unpaid data entry.
The problem usually isn't Django. It's the workflow around the PO file format. If you treat .po files as a handoff artifact for a portal or a contractor, they become stale fast. If you treat them like code, tracked in Git, reviewed in diffs, updated by commands, they become manageable.
You Ran makemessages. Now What?
A mid-sized Django app gets here quickly. You've wrapped strings with gettext_lazy, maybe added {% translate %} in templates, and extraction works. Then the actual work starts. Every locale now has a file like this:
#: apps/accounts/forms.py:18
msgid "Email address"
msgstr ""
#: templates/account/login.html:12
msgid "Sign in"
msgstr ""
#: apps/billing/views.py:44
msgid "Your subscription has expired."
msgstr ""
That file looks boring, but it's the center of a sane localization pipeline. GNU gettext uses .po as the editable message catalog, paired with .pot template files, and compiles translated catalogs into .mo files for runtime use, as described in the Pology PO format documentation.
What works is keeping the editable artifact in your repo:
- Commit the
.pofiles: Review text changes the same way you review code. - Avoid editing
.mofiles: They're runtime artifacts, not source. - Regenerate often: Run extraction whenever copy changes, not right before release.
- Keep translation close to code: Don't let source strings drift away from implementation.
Practical rule: If a copy change lands in Git without the matching
.podiff, your i18n pipeline is already behind.
When teams struggle here, it's usually because they separate translation from development too hard. The more useful model is “localization as code.” You update strings, regenerate catalogs, fill translations, review the diff, compile, ship. If you want a broader look at that mindset, this guide on how to do a translation in a developer workflow is worth a read.
The PO File Format Deconstructed
A .po file is the source of truth for translations you can review in Git. It is plain text, diffable, mergeable, and easy to validate in CI. That matters more than format trivia, because the file is part of the codebase, not a side channel owned by a separate portal.

The header block
The first entry has an empty msgid. It stores metadata that gettext and Django use while compiling and resolving translations.
msgid ""
msgstr ""
"Project-Id-Version: myapp\n"
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
Three fields deserve attention:
| Field | Why you care |
|---|---|
Language |
Tells tooling which locale this file targets |
Content-Type |
Encoding metadata, usually UTF-8 |
Plural-Forms |
Defines how many plural variants the locale needs |
Plural-Forms causes a lot of avoidable bugs. If it is wrong, translators can fill every msgstr correctly and the app will still choose the wrong plural branch at runtime.
A normal entry
Most entries are simple.
#: apps/accounts/forms.py:18
msgid "Email address"
msgstr "Adresse e-mail"
msgid is the source string extracted from code or templates. msgstr is the translation for one locale. The #: line points back to the source location, which makes code review and cleanup much easier when copy changes.
For a more developer-focused explanation of the format, this guide to gettext PO files in Django workflows is a solid reference.
Comments, flags, and references
Real files carry more than string pairs.
# Translator note: Keep the product name in English.
#. Shown on the pricing page CTA button
#: templates/pricing.html:24
#, fuzzy, python-format
msgid "Start your %(plan_name)s trial"
msgstr "Commencez votre essai %(plan_name)s"
Each prefix has a job:
#is a translator comment.#.is an extracted developer comment.#:is a source reference.#,stores flags such asfuzzyorpython-format.
fuzzy means review required. Sometimes gettext marks an entry fuzzy after the source string changes and the old translation is only a guess. Teams that commit fuzzy entries without checking placeholders usually discover the problem late, during QA or after release.
python-format is just as important. It tells tooling to treat placeholder syntax seriously. If a translator changes %(plan_name)s to %s or drops it entirely, you want that caught before deploy.
Context and plurals
At this stage, PO files stop being simple key value data and start carrying meaning your app depends on.
msgctxt "button"
msgid "Close"
msgstr "Fermer"
msgctxt disambiguates identical source strings. "Close" as a verb on a button is not the same as "close" as an adjective describing distance. Without context, translators have to guess. In a growing Django app, guesses turn into inconsistent UI text fast.
Plurals have their own structure:
#: apps/notifications/views.py:28
msgid "%(count)s unread message"
msgid_plural "%(count)s unread messages"
msgstr[0] "%(count)s message non lu"
msgstr[1] "%(count)s messages non lus"
This is one place where treating localization as code pays off. A PO diff shows whether the plural source changed, whether every required msgstr[n] exists, and whether placeholders still match. That review loop is much safer than copying strings into a spreadsheet and hoping they come back intact.
The format also has rough edges. Tool support is broad, but interoperability depends on gettext conventions more than a rigid universal spec. The OASIS XLIFF PO profile calls that out directly. In practice, that means sticking to standard gettext tooling unless you have a strong reason not to.
If your team already reviews serializer changes, migrations, and API contracts, review translation catalogs the same way. The habit is similar to what you would apply while following Codeling's Django API tutorial. Keep the source artifact in the repo, make diffs readable, and let automation catch mistakes before production.
From Code to Translation The Full Django i18n Workflow
In Django, the .po file sits in the middle of a sequence, not at the end of it.
Start in code:
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
PAGE_TITLE = _("Account settings")
def inbox_message(count):
return ngettext(
"%(count)s unread message",
"%(count)s unread messages",
count,
) % {"count": count}
Django's translation utilities are documented in the official i18n docs. Once strings are marked, extraction builds or updates your catalogs.
python manage.py makemessages --locale=fr
That writes to the standard layout:
locale/
└── fr/LC_MESSAGES/
├── django.po
└── django.mo
You usually won't see django.mo until you compile.

The three commands that matter
The core loop is short:
python manage.py makemessages --locale=fr
python manage.py compilemessages
There's a missing middle step there. Someone or something has to fill msgstr.
That can be a translator editing the PO file directly, a script that updates it, or a translation platform that imports and exports gettext catalogs. The important Django point is that you edit .po, not .mo.
Why .mo exists at all
Django serves translations from compiled message catalogs at runtime. You don't want the request path parsing text catalogs over and over. The readable source lives in .po. The deployable runtime artifact is .mo.
If you're building an API-first app and want a solid example project to wire localization into, Codeling's Django API tutorial is a good base because the routing and app layout are clear enough that adding locale handling doesn't become its own mess.
A practical warning: because PO is a de facto convention more than a tightly governed standard, different tools behave differently around headers, flags, and comments. Test your import and export path before your team commits to a portal or custom script.
Common Pitfalls Placeholders Plurals and Context
Most production i18n bugs don't come from missing translations. They come from valid-looking translations that break formatting or meaning.
Placeholders break quietly
This is a common source string in Django:
from django.utils.translation import gettext_lazy as _
greeting = _("Welcome back, %(name)s")
The corresponding PO entry might look like this:
#, python-format
msgid "Welcome back, %(name)s"
msgstr "Bon retour, %(name)s"
What you don't want is this:
#, python-format
msgid "Welcome back, %(name)s"
msgstr "Bon retour, %(nom)s"
That translation reads fine. It fails at runtime because your code still passes name, not nom.
A few rules help:
- Prefer named placeholders:
%(name)sis safer than%swhen translators need context. - Keep markup out when you can: Split presentation from text if HTML tags make the sentence harder to translate.
- Use extracted comments: Give translators enough context to preserve variables correctly.
Keep placeholders stable in source strings. If you rename them casually, you create review work and bug risk in every locale.
Plurals belong in gettext, not if/else
Don't write this:
label = "1 file" if count == 1 else f"{count} files"
Write this:
from django.utils.translation import ngettext
label = ngettext(
"%(count)s file",
"%(count)s files",
count,
) % {"count": count}
That produces a PO entry shaped for the locale's plural rules:
#, python-format
msgid "%(count)s file"
msgid_plural "%(count)s files"
msgstr[0] "%(count)s fichier"
msgstr[1] "%(count)s fichiers"
The point isn't syntax purity. Different languages need different plural logic, and the PO catalog carries that structure better than hand-written English branching in Python.
Context is the difference between “correct” and correct
A lot of strings aren't unique by text alone. “May” can be a month or a verb. “Open” can be a button label or an adjective. KDE's localization docs point out that a gettext message is only approximately identified by msgid, then correct that to the pair msgctxt + msgid in the KDE PO Odyssey.
In Django, use pgettext when the same English string means different things.
from django.utils.translation import pgettext
month_name = pgettext("month name", "May")
permission_text = pgettext("verb", "May")
That becomes two separate entries:
msgctxt "month name"
msgid "May"
msgstr "Mai"
msgctxt "verb"
msgid "May"
msgstr "Pouvez"
Without context, one translation overwrites the other in practice. With context, the catalog can hold both safely.
What usually works in review
A quick review pass should check these before merge:
| Check | Why it matters |
|---|---|
| Placeholder names unchanged | Prevents runtime formatting errors |
| HTML tags preserved | Avoids broken markup in templates |
| Fuzzy flags resolved | Avoids shipping unreviewed carry-overs |
msgctxt added for ambiguous strings |
Prevents wrong translations for reused UI text |
Automating PO File Translation in Your CI Pipeline
A familiar failure mode looks like this. A developer runs makemessages on Friday, sends the catalog out through a portal, gets a file back on Monday, and merges it without checking placeholder changes or fuzzy entries. The app still deploys, but the translation state now lives partly in Git and partly in someone else's UI. That split causes review problems fast.
For a Django team, .po files work better as source code artifacts. They diff cleanly, they belong in pull requests, and they fit the same automation model used for tests, formatting, and builds. The goal is simple. Extraction, translation, review, and compilation should all happen from commands your team can run locally and in CI.

The workflow that fits Django
Keep the pipeline boring:
python manage.py makemessages --locale=fr
python manage.py translate --target-lang=fr
python manage.py compilemessages
That middle command can be a custom management command, a translation vendor CLI, or a package like TranslateBot. The requirement is not the tool. The requirement is that it reads the checked-in .po file, updates msgstr, and writes the result back to the repo as a normal diff.
That design has real advantages:
- Git stays the source of truth: reviewers can inspect translation changes alongside the code that introduced them.
- CI and local development use the same commands: no hidden state in a browser-only workflow.
- Bad changes are easier to catch: placeholder damage, missing context, and accidental mass edits show up in the diff.
- Rollback stays simple: revert the commit and rebuild, instead of hunting through exports from an external system.
What belongs in automation, and what still needs review
Automate the repetitive part. Keep humans on the strings where a machine has the least context.
I usually require review for these categories:
- Short labels and buttons: one-word strings are often ambiguous.
- Marketing or product voice copy: literal translations are usually not good enough.
- Languages with complex plural behavior: the catalog structure supports it, but the wording still needs a check.
- Strings tied to legal, billing, or permissions flows: a slightly wrong translation becomes a support issue.
CI should enforce consistency, not pretend judgment is fully automated. If your team is already streamlining software delivery processes, localization should run in that same delivery path with the same expectations around review, repeatability, and rollback. For a concrete setup, TranslateBot documents a CI workflow for Django translation commands.
A Bulletproof Django Translation Workflow
Most translation pain comes from skipping a step and trying to patch it later. A reliable process is boring on purpose.

Keep the source of truth in your repo
Your deployable translation workflow should treat .po files like any other maintained source file.
- Mark strings consistently with
gettext_lazy,gettext,ngettext, andpgettext. - Run extraction early with
python manage.py makemessages --locale=<lang>. - Fill translations in the PO files, either manually or with automation.
- Review the diff for placeholders, context, fuzzy flags, and odd wording.
- Compile before deploy with
python manage.py compilemessages. - Smoke test the locale in the UI, especially forms, emails, and count-based messages.
The mistakes worth avoiding
Teams usually get burned by the same handful of habits:
- Editing compiled files:
.moisn't for humans. - Using English branching for plurals: put plural logic in gettext.
- Reusing ambiguous strings without context: add
pgettextbefore it becomes cleanup work. - Treating localization as a release-only task: update catalogs as copy changes.
The PO file format works best when it's boring. Generated from code, edited predictably, reviewed in Git, compiled at deploy time.
That's the practical takeaway. Don't build a side process around gettext. Let gettext sit inside your normal Django workflow.
If you want to keep that workflow inside manage.py, TranslateBot is built for exactly that pattern. It translates Django .po files and model fields, preserves placeholders and HTML, and fits between makemessages and compilemessages without pushing your team into a separate portal.