Meta description: Django software translation breaks on placeholders, plurals, and CI drift. Build a safer .po workflow that fits your release process.
You ran makemessages, opened locale/de/LC_MESSAGES/django.po, and got hit with a wall of empty msgstr "" entries. Then the usual bad options show up. Copy-paste into Google Translate. Send a spreadsheet to a contractor. Push strings into a portal your team hates using. Wait. Pull files back. Hope nobody broke %(name)s or HTML.
That's the point where translation of software stops being a language problem and turns into an engineering problem. Your app already has a release process, code review, CI, and rollback paths. Translations need to fit that same system or they become a source of regressions.
I've seen the same failure pattern over and over. Teams treat .po files like content blobs. They aren't. They're build inputs. If they change outside Git, outside review, or outside repeatable tooling, your multilingual app gets fragile fast.
You Ran makemessages. Now What?
The first run feels fine. A handful of strings. One locale. You edit them by hand and move on.
The second run is where it starts to rot. New strings arrive every sprint. Old strings change slightly. Someone marks a translation fuzzy. Another person edits the same file on a release branch. Your German file is current, your Spanish file is half-updated, and nobody knows whether the compiled .mo files match what's in the repo.
Practical rule: Treat
.pofiles like code artifacts, not editorial attachments.
What usually breaks isn't translation quality first. It's workflow.
- Copy-paste breaks context because short UI strings lose their surrounding code.
- Manual edits break placeholders when
%s,%(count)s, or{username}gets altered. - Portal-based work breaks reviewability when the source of truth lives outside your repo.
- Branching breaks consistency when two people touch the same locale file in parallel.
That's why a battle-tested workflow starts inside Django's existing toolchain. Keep extraction, translation, review, compilation, and deploy in one path. The result should be boring: reproducible diffs, deterministic output, and no surprises in CI.
Where Translation Fits in the i18n Pipeline
Django already gives you the internationalization half of the job. You mark strings with gettext or gettext_lazy, add LocaleMiddleware, configure languages, and structure locale files correctly. That's the wiring.
Localization is the adaptation work that happens after the codebase is ready. Translation is part of that, but it isn't the whole thing.

Django handles readiness, not completion
A useful mental model is this:
- i18n is preparing the app so different languages can exist without rewriting views, templates, and forms.
- l10n is adapting the shipped product for a locale.
- Translation is one artifact inside l10n, alongside date formats, currency display, copy tone, and market-specific choices.
If you work across web and mobile, the same split shows up outside Django too. A good example is Zephony's guide to implementing React Native internationalization, where the codebase gets prepared first and locale adaptation comes after.
For a Django refresher on the terminology, TranslateBot has a short explainer on what i18n means in practice.
Why this matters operationally
Software translation has moved out of the “nice to have” bucket. One 2026 industry summary on the AI language translation market says it grew from $1.88 billion in 2023 to $2.34 billion in 2024, a 24.9% CAGR. That lines up with what engineering teams already feel. Translation is now part of product infrastructure, not a side task for launch week.
Once you see it that way, the job changes. You're not just filling msgstr. You're maintaining a versioned multilingual build.
The Manual Workflow for Django .po Files'
Django's default path is still the right starting point. Mark strings, extract them, translate them, compile them. The problem isn't the basics. The problem is what happens when humans do all of it by hand.
The canonical flow
In Python:
from django.utils.translation import gettext_lazy as _
class BillingView:
page_title = _("Billing settings")
In templates:
<h1>{% load i18n %}{% translate "Billing settings" %}</h1>
<p>{% blocktranslate with name=user.first_name %}Welcome back, {{ name }}.{% endblocktranslate %}</p>
Then extract messages:
python manage.py makemessages -l de
That gives you a file like:
#: billing/views.py:4
msgid "Billing settings"
msgstr ""
#: templates/dashboard.html:2
#, python-format
msgid "Welcome back, %(name)s."
msgstr ""
Now you fill in msgstr manually:
#: billing/views.py:4
msgid "Billing settings"
msgstr "Abrechnungseinstellungen"
#: templates/dashboard.html:2
#, python-format
msgid "Welcome back, %(name)s."
msgstr "Willkommen zurück, %(name)s."
Then compile:
python manage.py compilemessages
The file lives where you expect:
locale/de/LC_MESSAGES/django.po

Where the manual path fails
The process above works for tiny apps. It starts falling apart once your product changes often.
A Redokun summary of translator tooling usage cites a survey where 88% of full-time professional translators use at least one CAT tool, and translators estimate software can raise productivity by at least 30%. That gap matters because most Django teams are still doing translator work with developer tooling alone.
The recurring failure modes are predictable:
- Placeholder drift when someone translates the words but not the format rules.
- Merge conflicts because
.pofiles reorder, fuzzies change, and multiple branches touch the same locale. - Inconsistent terminology across apps, emails, forms, and docs.
- Partial updates because nobody knows which strings are new versus changed.
- Out-of-band edits when a contractor returns a file that doesn't match the current branch.
If you need a quick refresher on the file structure itself, this overview of the gettext .po file format is useful.
A translation workflow that lives outside Git will eventually fight your release process.
Why Translating Software Is Not Just Translating Text
A novel can tolerate interpretation. Your UI can't. Software strings carry structure, grammar rules, and runtime data. Break any of those, and you ship bugs.

A practitioner guide on software translation notes that strings must fit UI constraints, support variables like {username}, and preserve pluralization rules. It also points out the engineering gap most articles skip: the hard part is proving translated output is deterministic, reviewable in Git, and safe for CI/CD, not just producing translated text (SimpleLocalize on software translation edge cases).
Placeholders break before wording does
Django uses multiple placeholder styles depending on the source string and interpolation path.
#, python-format
msgid "Hello, %(name)s"
msgstr "Hallo, %(name)s"
If %(name)s becomes %( Name )s, %name, or plain text, you don't get a slightly worse translation. You get a runtime failure or corrupted output.
The same problem shows up with brace formats and template variables:
msgid "You have {count} unread messages"
msgstr "Sie haben {count} ungelesene Nachrichten"
Generic translators often treat placeholders as words. They aren't words. They're part of the contract.
Plurals are grammar plus code
English makes people lazy here because singular and plural look manageable. Then you hit a locale with more plural forms and weak tooling starts guessing.
In Django:
from django.utils.translation import ngettext
message = ngettext(
"%(count)s file deleted",
"%(count)s files deleted",
count
) % {"count": count}
Generated .po entries need the plural forms preserved correctly:
msgid "%(count)s file deleted"
msgid_plural "%(count)s files deleted"
msgstr[0] ""
msgstr[1] ""
That's not optional formatting. It's executable behavior tied to locale rules.
Context is usually missing
Short UI strings are the worst candidates for blind automation.
msgid "Save"
msgstr ""
Is that a verb? A noun? A button label? A menu item? The same source token can require different translations depending on context. Django gives you pgettext for a reason.
from django.utils.translation import pgettext_lazy
label = pgettext_lazy("button label", "Save")
noun = pgettext_lazy("saved items section", "Save")
Without context, even a fluent translation can still be wrong.
HTML and markup need protection
A translated string can preserve meaning and still destroy rendering.
msgid "<strong>Warning:</strong> Your session expires in %(minutes)s minutes."
msgstr "<strong>Warnung:</strong> Ihre Sitzung läuft in %(minutes)s Minuten ab."
HTML tags, entities, and placeholders all need to survive unchanged. That's why code-integrated checks matter more than stylistic promises.
If your translation tool can't preserve placeholders and markup deterministically, don't put it in CI.
Choosing a Workflow Manual vs TMS vs AI
You've got three realistic options. None is magic. Each one solves a different problem and creates a different kind of friction.
Translation Workflow Comparison
| Criteria | Manual (e.g. Google Translate) | TMS Platform (e.g. Lokalise, Phrase) | CLI Tool (e.g. TranslateBot) |
|---|---|---|---|
| Primary workflow | Copy-paste in browser | Web portal with sync/import steps | Local command inside repo |
| Source of truth | Usually ad hoc files | Mixed between repo and vendor UI | Git-tracked .po files |
| Best use case | Very small apps, one-off tasks | Larger teams with dedicated localization ops | Engineering-led teams shipping often |
| Placeholder safety | Easy to break manually | Usually guarded by platform rules | Depends on tool, should be testable in code |
| Review model | Informal | In-platform review | Pull request review |
| Cost shape | Time-heavy, hidden labor | Recurring subscription | API usage plus dev dependency |
| Branching fit | Poor | Better, but often detached from Git flow | Strong if files stay in repo |
| Lock-in risk | Low | Higher | Lower if output stays standard .po |
A machine-first workflow is fine for technical content when you treat it as a draft layer. XTM's guidance on machine translation for technical workflows makes the trade-off clear: MT is useful for fast first drafts, but quality improves with human post-editing and controls like glossaries and translation memory.
What works in practice
Manual translation still has a place. It's acceptable when your app barely changes and one person owns every locale file. However, this approach is quickly outgrown.
TMS platforms help when multiple translators, reviewers, and PMs need shared workflow. The trade-off is process overhead. Developers leave the editor, state lives in another system, and the portal often becomes the de facto source of truth.
CLI-based AI translation fits a different team shape. It works best when engineers already own i18n and want translations to behave like any other generated artifact. If you're thinking about the broader automation pattern, PushOps has a good piece on building AI content generation workflows that maps well to translation pipelines too.
The trade-off that matters
Don't compare these options on “AI versus human” alone. Compare them on operational safety.
- Manual gives you direct control, but high toil.
- TMS gives you structure, but often moves work outside the repo.
- CLI AI gives you speed and Git-native review, but you still need human checks for risky strings.
The best setup for most Django apps is selective automation. Low-risk strings can move through an automated path. High-risk strings still need review.
How to Automate .po File Translation with a Single Command
The safest automation keeps Django's normal flow and inserts translation in the middle. Extract, translate, review, compile.

A practical command path looks like this:
python manage.py makemessages -l de
python manage.py translate --locale de
python manage.py compilemessages
That middle step only becomes safe when the tool writes back to standard .po files, preserves placeholders and HTML, and leaves a reviewable diff in Git. If it hides output in a vendor database, you lose most of the benefit.
What your CI should care about
A 2026 localization guide focused on hybrid workflows describes the direction clearly: AI or MT first, human review for high-risk text, with the central question being which parts can be safely automated in products that ship continuously. That's the right frame for Django too.
Here's a minimal GitHub Actions example:
name: i18n
on:
pull_request:
jobs:
translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python manage.py makemessages -l de
- run: python manage.py translate --locale de
- run: python manage.py compilemessages
Place the human review where it belongs, in the pull request. Review changed msgstr entries the same way you review migrations or generated client code.
What to check before merge
Don't review every translated string with the same intensity. Review the risky ones.
- UI actions:
Save,Back,Close,Continue - Plural strings: anything using
ngettext - Interpolated text:
%s,%(name)s,{count} - HTML-bearing strings: tags, links, line breaks
- Brand-sensitive copy: pricing pages, onboarding, legal text
If you want a concrete walkthrough of the command-driven approach, TranslateBot documents how to automate translation in a Django workflow. The useful part isn't the package name. It's the shape of the workflow: one command, standard .po output, Git diffs, and CI-safe automation.
A short demo helps if you want to see the pattern in motion:
Run the pipeline locally before you wire it into deploys. Start with one locale. Check placeholder integrity, review the diff, compile messages, and only then add it to CI.
If you want that workflow without a portal, TranslateBot is built for it. It plugs into makemessages and compilemessages, translates .po files from a Django management command, preserves placeholders and HTML, and keeps everything reviewable in Git.