Back to blog

How to automate your Django translation workflow

2026-07-11 9 min read

If you have worked with Django's translation system for any length of time, you know the drill. You mark strings with _(), run makemessages, open a .po file, and stare at hundreds of empty msgstr fields. Then you do it again for every language your app supports.

The process works. It also eats hours that could go toward building features. A project with 200 translatable strings across five langauges means 1,000 translations to manage. Change a button label and you need to update all five .po files before the next deploy, or ship partially translated pages.

This article walks through the manual workflow, shows where it breaks down, and covers practical ways to automate it without losing control over quality.

The manual workflow

Django's built-in translation pipeline has four steps:

  1. Mark strings with gettext or {% trans %} in your code and templates
  2. Run makemessages to extract them into .po files
  3. Translate the msgstr entries in each .po file
  4. Run compilemessages to produce the binary .mo files Django reads

Steps 1 and 2 are fast. Step 4 is one command. Step 3 is where everything slows down.

What step 3 looks like in practice

Open locale/de/LC_MESSAGES/django.po in your editor. You see something like this:

#: myapp/views.py:42
msgid "Your subscription has been renewed."
msgstr ""

#: myapp/templates/dashboard.html:18
msgid "Welcome back, %(username)s"
msgstr ""

#: myapp/forms.py:15
msgid "This field is required."
msgstr ""

For each entry, you need a correct translation that preserves any placeholder variables like %(username)s or {count}. Break a placeholder and Django will throw a formatting error at runtime.

With five languages and a hundred new strings, you are looking at a full afternoon of copy-pasting between translation services, fixing placeholder formats, and hoping you did not miss anything. And next sprint, you do it again.

Where the manual approach breaks down

Small projects can get by with manual translation. Once a project crosses a few threshholds, the workflow starts to hurt.

Growing string counts

A Django app with a dozen templates, a few forms, and some email notifications can easily reach 300 translatable strings. Multiply by the number of supported languages and the total climbs fast. At some point, nobody wants to be the person who opens that .po file.

Stale translations

When developers change existing strings, makemessages marks the old translations as fuzzy. These fuzzy entries need manual review, and they tend to pile up because nobody notices them until a user reports seeing English on a German page. The fuzzy backlog quietly grows between releases.

Placeholder errors

Django's .po format uses several placeholder styles: %(name)s for Python's % formatting, {name} for .format() and f-strings, and {{ name }} for template variables. Translators (human or machine) sometimes mangle these, and the errors only surface at runtime. A mismatched %(username)s in a German translation can crash a view that works fine in English.

Blocking deployments

If translations are a manual step in your deployment pipeline, they become a bottlneck. Either you wait for translations before deploying (slow), or you deploy with missing translations (bad user experience). Neither option is good when you are shipping weekly.

What automation looks like

Automating Django translations means replacing step 3 (the manual translation part) with a programatic process while keeping everything else the same. Your makemessages and compilemessages workflow does not change. You just stop hand-translating .po files.

Option 1: script it yourself

You can write a script that reads a .po file, sends untranslated strings to a translation API, and writes the results back. The basic version is surprisingly short:

import polib
from openai import OpenAI

client = OpenAI()
po = polib.POFile("locale/de/LC_MESSAGES/django.po")

for entry in po.untranslated_entries():
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Translate to German. Preserve all "
             "placeholder variables like %(name)s exactly as they appear."},
            {"role": "user", "content": entry.msgid},
        ],
    )
    entry.msgstr = response.choices[0].message.content.strip()

po.save()

This gets you 80% of the way there. The remaining 20% is where it gets complicated:

  • Plural forms (msgid_plural and multiple msgstr[n] entries) need special handling, because different languages have different plural rules
  • Context strings (msgctxt) should be passed to the API so the translation fits the usage
  • You need to validate that placeholders survived the translation intact
  • Rate limits and API errors need retry logic
  • The script needs to handle .po file encoding correctly (UTF-8 with specific header fields)
  • Fuzzy entries need different treatement than untranslated ones

Building all of this yourself is doable, but it takes a few days of work and ongoing maintenance. Every time a translation API changes its behavior or pricing, you need to update your script.

Option 2: use a translation management platform

Services like Crowdin, Transifex, and Lokalise integrate with version control and provide a web interface for translators. They handle .po files natively, support plural forms, and can set up automated workflows with machine translation as a first pass.

The tradeoff is cost and complexity. These platforms are built for teams with dedicated translators, and the pricing reflects that. For a solo developer or a small team that just needs .po files translated, they add more infrastrucutre than necessary.

Option 3: purpose-built tools

Tools built specifically for Django's .po file workflow can automate translation while respecting the format's quirks. They handle plural forms, preserve placeholders, manage fuzzy entries, and integrate with your existing makemessages/compilemessages pipeline.

The advantage is that they are designed for exactly this use case, so there is less glue code to write and maintain. The disadvantage is that you are adding a dependency to your translation workflow.

Setting up automated translations in CI/CD

Regardless of which tool you choose, the integration with your deployment pipeline follows the same pattern:

# .github/workflows/translate.yml
name: Update translations

on:
  push:
    branches: [main]

jobs:
  translate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install django

      - name: Extract new strings
        run: python manage.py makemessages -a --no-obsolete

      - name: Translate .po files
        run: python scripts/translate.py  # your translation script

      - name: Compile messages
        run: python manage.py compilemessages

      - name: Commit and push
        run: |
          git config user.name "github-actions"
          git config user.email "actions@github.com"
          git add locale/
          git diff --staged --quiet || git commit -m "chore: update translations"
          git push

The workflow extracts new strings, translates them, compiles the results, and commits the updated files. The git diff --staged --quiet check avoids creating empty commits when nothing changed.

You can also run this as a pre-commit hook or a scheduled job. The key idea is the same: translation happens automaticaly as part of your normal development process, not as a separate manual step.

Validating translations

Automated translations need validation. Even the best AI models occasionally break placholder formats or produce translations that do not fit the UI context.

Placeholder validation

Write a check that compares placeholders between msgid and msgstr:

import re
import polib

PLACEHOLDER_RE = re.compile(r"%\(\w+\)[sd]|\{\w+\}|\{\{.*?\}\}")

def validate_po(path):
    po = polib.POFile(path)
    errors = []
    for entry in po.translated_entries():
        src = set(PLACEHOLDER_RE.findall(entry.msgid))
        dst = set(PLACEHOLDER_RE.findall(entry.msgstr))
        if src != dst:
            errors.append(f"Mismatch: {entry.msgid!r}")
    return errors

Run this in CI and fail the build if any placeholders are broken. It takes seconds and catches the most dangerous class of translation errors before they reach producion.

Length checks

Some translations are significantly longer than the source string. German text is typically 20-30% longer than English, and this can break fixed-width UI elements. A simple length ratio check can flag translations that might cause layout issues.

Fuzzy entry auditing

After automated translation, check that no entries are left as fuzzy. Fuzzy entries mean Django will not use the translation, falling back to English instead. Your CI pipeline should treat fuzzy entries as a failing condition, the same way it treats a failing test.

Keeping translations in sync

The hardest part of translation maintanence is not the initial translation. It is keeping translations up to date as your codebase evolves.

String changes

When you change an existing translatable string, makemessages does something subtle: it looks for a similar string in the .po file and creates a fuzzy match. The old translation is preserved but marked as needing review. This is useful when a human translator is reviewing changes, but it means automated workflows need to handle fuzzy entries specifically.

A practical approach: clear the fuzzy flag and re-translate the entry from scratch. Fuzzy matching saves work for human translators, but an automated tool can just translate the new string directly. The result is usually better than trying to patch an old translation to fit a new source string.

New strings

New strings show up as untranslated entries after running makemessages. Your automated workflow should translate these and commit the results. If you run makemessages in CI, new strings get translated automatically when merged.

Removed strings

Running makemessages --no-obsolete removes entries for strings that no longer exist in your code. This keeps .po files clean and avoids accumulating dead translations over time.

What to check before you automate

Before setting up automated translation, verify a few things:

  • All translatable strings are properly marked with _() or {% trans %}. Run makemessages and check for strings you missed.
  • Your LOCALE_PATHS setting is correct and your locale/ directory structure is in place.
  • compilemessages runs without errors on your existing .po files.
  • You have a way to review automated translations before they go live, even if it is just a staging environment where someone can click through the app in each language.

Translation automation works best when the rest of your i18n setup is solid. Automating step 3 will not help if steps 1 and 2 are broken.

Practical tips

A few things I have learned from working with automated Django translations:

  • Run makemessages with --no-obsolete to keep .po files clean. Dead entries confuse both human reviewers and automated tools.
  • Use msgctxt when the same English string needs different translations in different contexts. "Post" can mean either a blog post or the act of posting, and without context the translator (human or automated) has to guess.
  • Test with languages that are structurally different from English. German's longer words and Japanese's character set catch layout bugs that European languages miss.
  • Keep a TRANSLATING.md file with project-specific terminology and tone guidelines. If your project calls a "workspace" a "project" in the UI, document that. Some automated translation tools can read this file and apply your preferences consistently.
  • Commit .po and .mo files together. If you compile messages in CI, make sure the .mo files are included in your deploy artifact. Django reads .mo files, not .po files.

Comparing approaches

For a solo developer or small team with a Django project supporting 3-5 languages:

A custom script works if you enjoy maintaining it and your string count is under 200. Beyond that, the edge cases around plural forms, placeholders, and fuzzy entries start eating into the time you saved.

Translation management platforms make sense if you have a team of translators working across multiple projects. For a single Django app, they are usually overkill.

Purpose-built tools hit the middle ground: they handle .po file quirks without requiring you to build and maintain the integration yourself. The monthly cost is usually less than the engineering time spent on a custom solution.

Pick whichever approach matches your team size and willingness to maintain translation infrastructure. The important thing is that translations stop being a manual bottleneck and start happening as part of your normal development cycle.

Stop editing .po files manually

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