Skip to content

PO File Translation

TranslateBot Django automatically translates your Django .po (gettext) files, which are the standard way Django handles internationalization.

How It Works

  1. Scans your project for .po files (django.po and djangojs.po) in:
    • LOCALE_PATHS directories
    • App-specific locale/ directories (apps installed in site-packages are skipped)
    • The locale/ directory in BASE_DIR, only when LOCALE_PATHS is empty
  2. Identifies entries to translate: empty and fuzzy ones
  3. Sends messages to the AI model with instructions to preserve formatting, along with their msgctxt and translator comments (LLM providers only)
  4. Updates the .po file with translations, saving after every batch
  5. Skips obsolete entries and third-party packages automatically

Basic Usage

Translate to a Specific Language

python manage.py translate --target-lang nl

Translate to All Languages

If you have LANGUAGES defined in settings:

python manage.py translate

This translates to every language in LANGUAGES except the source language (LANGUAGE_CODE).

Preview Mode

See what would be translated without making API calls or changes:

python manage.py translate --target-lang nl --dry-run

Dry Run Output

ℹ️  Found 15 untranslated entries
🔍 Dry run mode: skipping translation

Processing: /path/to/locale/nl/LC_MESSAGES/django.po
✓ Would translate 'Welcome to our site'
✓ Would translate 'Hello, %(name)s!'
...
Dry run: 15 entries would be updated in /path/to/locale/nl/LC_MESSAGES/django.po

============================================================
Dry run complete: 15 entries would be translated across 1 file(s)

Dry run mode skips the translation API entirely, so there are no costs. It lists which strings need translation, not what they would be translated to.

Re-translate Existing Entries

By default, only empty and fuzzy entries are translated. Translated fuzzy entries lose their fuzzy flag. To re-translate everything:

python manage.py translate --target-lang nl --overwrite

Warning

This will replace all existing translations. Consider using --dry-run first.

Plural Forms

TranslateBot handles Django's plural forms (msgid_plural / msgstr[n]) automatically. Already-translated plural entries are skipped, just like regular entries.

With an LLM provider, every plural form of the target language gets its own translation. TranslateBot reads the Plural-Forms header of the .po file and tells the model which counts use which form. makemessages writes this header for most languages; if it's missing or still the nplurals=INTEGER; plural=EXPRESSION; placeholder, a warning is logged and that file's plurals are translated like with DeepL (below). Polish, for example, has three:

Form Used for Example
msgstr[0] 1 %(count)d plik
msgstr[1] 2, 3, 4, 22, 23, … %(count)d pliki
msgstr[2] 0, 5, 6, 7, 8, … %(count)d plików

DeepL

DeepL can't translate into specific plural forms. With DeepL, the singular and plural are translated as two strings, and the plural translation is used for every form after the first. Review plural entries for languages with more than two forms (Polish, Russian, Czech, Arabic, …).

Message Context

Entries with the same msgid but a different msgctxt (from pgettext() or {% translate "May" context "month" %}) are translated separately, and the context is passed to the model to tell them apart:

pgettext("month", "May")  # → "maj" (Polish)
pgettext("permission", "May")  # → "może"

Extracted translator comments (#. lines, from # Translators: comments in your code) are passed along in the same way.

DeepL

DeepL doesn't receive the context or comments, so with DeepL entries that differ only in msgctxt are translated identically.

A message that appears in several .po files for the same language, such as django.po and djangojs.po, is translated once and written to every file where it still needs a translation. Files that use a different app-level TRANSLATING.md get their own translation.

Placeholder Preservation

TranslateBot preserves all Django placeholders:

Type Example Preserved
Named %(name)s, %(count)d Yes
Positional %s, %d Yes
Format strings {0}, {name} Yes
HTML tags <strong>, <a href="..."> Yes
Line breaks \n Yes

Example

Source:

Welcome to %(site_name)s! You have <strong>%(count)d</strong> new messages.

Translated (Dutch):

Welkom bij %(site_name)s! Je hebt <strong>%(count)d</strong> nieuwe berichten.

Translation Validation

Every translation is checked before it's written, using the rules msgfmt --check-format (run by compilemessages) applies to python-format and python-brace-format entries:

  • Named placeholders and {fields} must match the source and keep their type: %(n)d can't become %(n)s.
  • Positional %s / %d must match in number and order.
  • A literal percent sign must stay %%.
  • A {field}'s format spec must stay as in the source: {a:.2f} can't become {a:,.2f} or {a}.
  • Plural forms are checked against msgid_plural and may leave named placeholders out ("one file"), except in languages with a single form (Japanese, Chinese, …).

Empty translations and index markers (#1, #2, …) are rejected too.

Rejected translations are retried once, on their own. If they fail again, they don't break compilemessages: an untranslated entry gets the translation as a draft marked #, fuzzy. compilemessages skips fuzzy entries (unless run with --use-fuzzy), PO editors flag them for review, and the next translate run translates them again. A draft never replaces an existing translation, not even with --overwrite.

The run ends with a list of every rejected translation, per file:

⚠️  1 translation(s) failed validation, also on retry:
  locale/nl/LC_MESSAGES/django.po
    'Welcome back, %(name)s!' (marked fuzzy): missing placeholders: %(name)s

To fix one by hand, correct the placeholders in your PO editor and remove the fuzzy flag. Drafts are fuzzy entries, so check_translations fails until they're reviewed.

Intelligent Batching

TranslateBot groups strings into batches based on the AI model's token limits. This:

  • Maximizes efficiency by reducing API calls
  • Handles large projects with thousands of strings
  • Automatically adjusts batch size based on content length

Each batch's expected output is kept under 8,000 tokens, even when the model allows more, because long JSON responses get less reliable. If LiteLLM doesn't recognize the model name, TranslateBot logs a warning and falls back to small batches (8,192 input / 4,096 output tokens). The run still works, but it makes more API calls.

Translations are saved after every batch, so if a later batch fails, the earlier ones are kept. Rerunning the command picks up where it stopped (unless you use --overwrite, which starts over).

Workflow Example

# 1. Mark strings in your code
from django.utils.translation import gettext as _
message = _("Welcome to %(site_name)s!")

# 2. Generate .po files
python manage.py makemessages -l de -l fr -l nl

# 3. Preview translations
python manage.py translate --dry-run

# 4. Apply translations
python manage.py translate

# 5. Compile for use
python manage.py compilemessages

Best Practices

Use Dry Run First

Always preview translations with --dry-run before applying them to catch any issues.

Commit Before Translating

Commit your .po files to version control before running translations, so you can easily revert if needed.

Provide Context

Create a TRANSLATING.md file in your project root to provide domain-specific context, terminology guidelines, and tone preferences. See Translation Context for details.

Review Translations

While AI translations are generally good, consider having a native speaker review critical user-facing text.

Next Steps