Back to blog

The PO File Format: A Django Developer's Guide

2026-06-09 11 min read
The PO File Format: A Django Developer's Guide

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:

Practical rule: If a copy change lands in Git without the matching .po diff, 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.

An infographic diagram deconstructing the key components of a PO file format used for software localization.

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:

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.

A diagram illustrating the Django internationalization workflow from source code integration to rendered localized content.

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:

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.

Screenshot from https://translatebot.dev

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:

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:

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.

A six-step checklist for maintaining a professional and efficient Django translation workflow using standard tools.

Keep the source of truth in your repo

Your deployable translation workflow should treat .po files like any other maintained source file.

  1. Mark strings consistently with gettext_lazy, gettext, ngettext, and pgettext.
  2. Run extraction early with python manage.py makemessages --locale=<lang>.
  3. Fill translations in the PO files, either manually or with automation.
  4. Review the diff for placeholders, context, fuzzy flags, and odd wording.
  5. Compile before deploy with python manage.py compilemessages.
  6. 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:

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.

Stop editing .po files manually

TranslateBot automates Django translations with AI. One command, all your languages, pennies per translation.