Meta description: Django web site localization breaks when empty .po files pile up. Build a safe CI/CD workflow for translating, reviewing, and shipping locales.
You run python manage.py makemessages, open locale/fr/LC_MESSAGES/django.po, and get a wall of empty msgstr entries. Then the release stalls.
That's the part most web site localization articles skip. Django already gives you extraction, compilation, and locale routing. The painful gap is everything in the middle. Someone has to translate new strings, preserve placeholders, avoid breaking HTML, keep terminology consistent, and do it again next sprint when product copy changes.
Many engineering teams don't fail at translation; instead, they struggle with operating localization. General guidance keeps treating localization like a content project, but engineering teams need a workflow that is version-controlled, reproducible, and safe under constant change, which is the gap called out in this website localization guide. If you've also dealt with string drift across mobile and web, Zephony's guide to avoiding localization tech debt is worth your time because the failure mode is the same even when the stack changes.
The Standard Django i18n Workflow and Its Breaking Point
Django's built-in flow is fine until your app stops being tiny.
You mark strings with gettext_lazy, add LocaleMiddleware, generate message catalogs, and compile them. That part is boring, which is good.
from django.utils.translation import gettext_lazy as _
class BillingView(TemplateView):
page_title = _("Billing")
python manage.py makemessages --locale=fr --locale=de
python manage.py compilemessages
Your tree ends up looking like this:
locale/
├── de/LC_MESSAGES/django.po
└── fr/LC_MESSAGES/django.po
Where the built-in flow stops
Now open one of those files.
#: billing/templates/billing/checkout.html:12
msgid "Pay now"
msgstr ""
#: billing/forms.py:48
#, python-format
msgid "Card ending in %(last4)s"
msgstr ""
#: accounts/templates/accounts/welcome.html:9
msgid "<strong>Welcome back</strong>"
msgstr ""
#: dashboard/views.py:31
msgid "View"
msgstr ""
Django did its job. Your app is internationalized. Your product is not localized.
That empty catalog becomes a release dependency. Product wants French this sprint. Support wants German next month. Marketing changes five CTA strings on Friday. Now somebody has to fill msgstr, review it, commit it, and hope they didn't damage %() placeholders or tags in the process.
Empty
.pofiles are not a translation problem. They're an engineering queue problem.
What breaks in real projects
The breaking point usually looks familiar:
- Strings change constantly.
msgidchurn creates stale translations every sprint. - Context gets lost. “View” in a button and “view” in a database sense shouldn't share a translation.
- Manual work spreads. Engineers export files, PMs email vendors, someone pastes results back.
- Diffs become noisy. You can't tell what changed because the process happened outside Git.
For teams shipping frequently, the question isn't how to localize a web site once. It's how to keep the process from slowing every deploy.
Django's stock workflow is still the right base. You just need a translation layer that behaves like the rest of your build system.
A Repeatable Version-Controlled Translation Foundation
If your source strings are sloppy, every downstream translation tool gets worse.
Start by refining the English source text. That sounds obvious, but this step is frequently skipped. Short labels, overloaded words, missing context, and mixed casing all create avoidable translation noise.

Use context before you use automation
Django gives you pgettext and npgettext for a reason. Use them when a string can mean different things in different places.
from django.utils.translation import pgettext, gettext_lazy as _
button_label = pgettext("profile action button", "View")
db_term = pgettext("database noun", "View")
page_title = _("Settings")
That produces separate entries in the catalog, with contextual comments translators can use.
msgctxt "profile action button"
msgid "View"
msgstr ""
msgctxt "database noun"
msgid "View"
msgstr ""
Without context, your French or German file will eventually contain a “correct” translation that is wrong half the time.
Treat locale files like code
Keep your .po files in Git. Review them in pull requests. Don't move them into a portal unless you have a strong reason.
A workable baseline looks like this:
- Stable extraction. Run
makemessagesthe same way every time. - Clear locale layout. Keep
locale/<lang>/LC_MESSAGES/django.popredictable. - Small diffs. Only commit real string changes, not random reformatting.
- Compiled output out of Git. Commit source
.pofiles, not generated.mofiles unless your deploy setup requires it.
If you need a refresher on how gettext catalogs behave in practice, this gettext .po file guide is a good reference.
Practical rule: if a translator can't infer meaning from the
msgid, add context in code instead of hoping review will catch it later.
Add measurement, not just translation
Localization shouldn't be treated like a one-off task. A higher-signal workflow treats it as a release pipeline and checks behavior per locale with analytics like bounce rate and conversion rate, because poor localization creates usability issues that suppress conversions, as Smartling notes in its website localization workflow guidance.
That doesn't mean every team needs a huge localization dashboard on day one. It means each locale should be observable. If a translated checkout starts leaking users, you need to know whether the issue is copy, layout, date formatting, or payment expectations.
A clean source catalog plus observable locale behavior is the foundation. Everything else sits on top of that.
Automating .po File Translation Safely
Once your catalogs are clean, you can automate the boring middle.
The safe pattern is narrow. Read the source .po, find untranslated or changed entries, send only the needed text to a provider, write results back into msgstr, and leave everything else alone. No browser portal. No CSV export. No hand-edited copy-paste loops.

What must never change
A translation command is only useful if it preserves Django-specific syntax.
That includes:
- Python placeholders like
%(name)s - Old-style placeholders like
%s - Brace placeholders like
{0} - Inline HTML when it exists in source strings
- Plural structures and metadata
- Fuzzy state when you need review before shipping
Here's the kind of entry that gets broken by unsafe tooling:
#: notifications/views.py:22
#, python-format
msgid "Hello %(name)s, you have %s unread messages."
msgstr ""
A safe result preserves the format markers exactly:
#: notifications/views.py:22
#, python-format
msgid "Hello %(name)s, you have %s unread messages."
msgstr "Bonjour %(name)s, vous avez %s messages non lus."
Same story for HTML:
#: marketing/templates/hero.html:7
msgid "<strong>Start free</strong> today"
msgstr "<strong>Commencez gratuitement</strong> aujourd’hui"
If the tags move, break, or disappear, your front end can render garbage.
How the CLI should behave
The ideal command sits next to makemessages and compilemessages in your normal flow. One concrete option is translatebot-django, which adds a manage.py translate command for translating .po files and model fields while preserving placeholders and HTML.
A typical run looks like this:
python manage.py makemessages --locale=fr
python manage.py translate --locale=fr
python manage.py compilemessages
That pattern works because it stays inside the Django workflow you already trust.
For teams using machine translation with review, the discipline is the same whether the provider is GPT-4o-mini, Claude, Gemini, or DeepL. Translate only the changed units. Keep output in the .po file. Review diffs in Git. Then compile.
If you want a grounded review model after machine translation, this machine translation post-editing guide covers where review pays off.
What not to automate blindly
Don't let a model free-run across every string with no guardrails.
These entries need extra care:
| String type | Safe to auto-translate | Review expectation |
|---|---|---|
| Long product descriptions | Usually yes | Spot check |
| Short UI labels | Sometimes | High |
| Checkout copy | With caution | Required |
| Legal text | No auto-only release | Required |
| Error messages with placeholders | Yes, if format-safe | Required |
Machine translation without placeholder checks is a bug generator, not a localization workflow.
Also watch the fuzzy flag. If msgid changes enough to invalidate a prior translation, your pipeline should surface that entry for review instead of pretending nothing happened.
Enforcing Consistency with a Project Glossary
Most translation inconsistency doesn't come from the model. It comes from vague source language and a missing glossary.
A Django team usually doesn't need a dedicated terminology portal. A repo file works better because it lives with the code, changes in pull requests, and doesn't require anyone to log into another system.

Why TRANSLATING.md beats a spreadsheet
A spreadsheet goes stale fast. Nobody knows which version is current. Engineers don't see terminology changes during code review. Contractors keep local copies. Six weeks later, “workspace,” “team space,” and “organization” are all translated differently across the app.
A file in the repo fixes most of that.
# TRANSLATING.md
## Do not translate
- TranslateBot
- SaaS
- API
- CSV
## Preferred product terms
- Settings -> Paramètres (not Réglages) in French
- Workspace -> Espace de travail
- Billing -> Facturation
## Tone
- Use informal second person in French UI copy
- Keep button labels short
- Do not translate placeholder names
## Technical rules
- Preserve %(name)s, %s, and {0}
- Preserve HTML tags exactly
- Keep sentence case for headings
That file does two jobs. It guides humans, and it gives any automated translation step project-specific context.
Keep the glossary small and opinionated
Don't dump your whole style guide into it. Add only the terms that cause drift.
A useful glossary usually covers:
- Brand names that should never be translated
- Domain terms like billing, workspace, seat, plan
- Tone choices such as formal vs informal second person
- Formatting rules for placeholders, tags, and capitalization
For source writing itself, Dokly's writing for translation guide is a solid reminder that many localization problems start before a string reaches a translator.
Good glossary files don't try to explain language. They record decisions your team already made.
When you review a PR and notice a term drift, update TRANSLATING.md in the same branch. That's a cleaner feedback loop than filing a note in some external tool nobody checks.
Wiring Web Site Localization into Your CI/CD
Localization gets easier the moment it stops being a side quest.
If your team merges English copy changes all week and translates once a quarter, every locale becomes stale. Forrester's localization research notes that brands have to prioritize what gets localized and that 41% of marketers still use a highly manual process, which is exactly why engineering-led automation matters. The same roundup highlights examples where localization has been associated with stronger search traffic and user growth, including a 47% increase in search traffic and nearly 60% more new users in one cited case after localization, from Forrester's website localization discussion.

A GitHub Actions example
You don't need a giant platform for this. A job in CI is enough.
name: localization
on:
pull_request:
push:
branches: [main]
jobs:
translate:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install system gettext
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Extract messages
run: |
python manage.py makemessages --locale=fr --locale=de
- name: Translate changed strings
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python manage.py translate --locale=fr
python manage.py translate --locale=de
- name: Compile messages
run: |
python manage.py compilemessages
- name: Run tests
run: |
python manage.py test
That gives you translated strings in the branch where the feature is being built. Designers can catch overflow early. QA can click the localized UI before release. Product can review the exact copy diff that shipped.
What to check in CI
Don't stop at translation and compilation. Add checks that catch common regressions.
- Fail on broken placeholders. If
%()markers changed, block the build. - Render templates in each target locale. Catch missing tags and truncation.
- Test critical pages. Login, pricing, checkout, and emails.
- Review right-to-left separately. If you support an RTL locale, treat layout as a first-class test case.
If you need ideas for locale-aware assertions, this collection of translation test examples is useful.
A lot of standard CI advice applies here too. Wonderment Apps has a practical guide to modern software delivery that lines up well with keeping localization inside normal delivery pipelines instead of bolting it on after the fact.
Why branch-level localization matters
When localization only happens after merge, every problem is more expensive.
Developers can't see string expansion in the feature branch. Reviewers can't spot context mistakes while the code is fresh. PMs approve copy in screenshots instead of the actual UI. By the time translators or QA notice an issue, the author has moved on.
Localized diffs in Git are easier to trust than screenshots in a ticket.
That's the operational win. Web site localization stops being a late-stage content handoff and becomes a normal artifact of shipping software.
Balancing Automation Speed Cost and Quality
Automation is cheap compared with traditional localization tooling. It's also not enough on its own.
The quality line should follow user risk. Product grid labels, help text, and admin screens are good automation candidates. Checkout, pricing, legal text, and regulated content need human review. That isn't anti-automation. It's just sane release management.
Research gathered in Sawa Tech's localization write-up reports that 76% of online shoppers prefer to buy from sites with product information in their own language, and 40% are less likely to buy if the page is not localized. The same guidance warns against machine-translation-only deployment for pages like checkout and legal text, and recommends human review and QA for issues like broken buttons, truncated UI, and RTL errors in web site localization work (Sawa Tech website localization guide).
Where AI translation still struggles
The weak spots are predictable:
- Short UI strings with no context, like “View”, “Apply”, or “Charge”
- Plural-heavy locales where singular and plural forms aren't enough
- Gendered agreement in languages where surrounding grammar matters
- Legal and financial language where wording carries liability
- CJK and RTL presentation issues that aren't translation errors at all
A model can produce fluent text and still be wrong for the screen, the action, or the jurisdiction.
Localization Workflow Cost Comparison Illustrative
| Method | Initial Cost | Monthly Cost (Maintenance) | Workflow |
|---|---|---|---|
| AI translation in a CLI workflow | Low | Usage-based | Run from Django commands, review diffs in Git, add human review for key strings |
| TMS subscription | Moderate to high | Recurring subscription | Sync strings to a portal, manage reviewers and glossaries outside the repo |
| Human translation agency | Low setup, high per batch | Variable by volume | Send source content for translation, wait for delivery, review and import |
Public pricing context helps frame the trade-off. Lokalise starts around $140/month/project, Crowdin around $50/month on Pro, Phrase Strings around $27/user/month on Team, and Smartling is enterprise quote only, based on the pricing context provided in the brief. Human translation rates are commonly discussed per word, while AI API usage is billed by tokens or characters. The point isn't that one category wins every time. It's that subscription-heavy workflows often fit worse when your app changes every week and you only need changed .po entries translated.
A release policy that works
Use a hybrid rule set and keep it boring:
| Content area | Default handling | Release rule |
|---|---|---|
| Marketing pages | Auto-translate, then review selected pages | Native review before campaign launch |
| App UI | Auto-translate with glossary | Review in PR for changed screens |
| Checkout and billing | Auto-assist only | Human review required |
| Legal and compliance | Human-authored or human-reviewed | Never ship auto-only |
| Support docs | Auto-translate first pass | Revise based on user feedback |
If you need one decision rule, use this: the more a string affects money, trust, or legal exposure, the more human review it gets.
That's enough to keep costs down without pretending the model is infallible.
If your Django team is stuck between manual .po editing and a full TMS, TranslateBot is one practical option to test. It runs as a Django management command, translates .po files in place, preserves placeholders and HTML, and keeps the whole workflow inside Git so you can review localization like any other code change.