Auto-Translate Gettext .po Files in CI (Django, Phoenix, LinguiJS)

· 8 min read · by Arvid Andersson
Gettext Django Phoenix LinguiJS CI/CD
Auto-Translate Gettext .po Files in CI (Django, Phoenix, LinguiJS)

Gettext is the oldest i18n format still in daily use, and it's everywhere: Django ships it, Phoenix ships it, LinguiJS can store its catalogs in it. If your product speaks more than one language and runs on any of those stacks, there's a good chance your translations live in .po files, and a good chance a PR is sitting unmerged right now because some of them are empty.

We've written about translating JSON and YAML locale files in CI; this is the gettext companion. The CI loop is the same. What's different is the format, and one property of it in particular: in gettext, the key and the copy are the same thing.

The msgid is the key

In JSON or YAML, a translation key is a stable name you invent: checkout.title. In classic gettext, the key is the source text itself. msgid "Hello" maps to msgstr "Hej", and the English string is the only thing connecting them.

Two panels: source text chained to its translation, then the edited text with the chain snapped and the translation falling

So when someone edits copy, Hello becomes Hello there, the key changes, and every translation of the old string is orphaned. The gettext ecosystem's answer is fuzzy matching: on merge, the tooling finds the closest old msgid, carries its translation over, and marks the entry fuzzy for a human to review. It's a heuristic (Elixir's gettext uses Jaro distance), and a big enough edit falls below the threshold and loses the translation entirely.

What happens to those fuzzy entries is where the frameworks quietly disagree:

  • Django refuses to compile them. compilemessages skips fuzzy entries by default, so the user silently sees English until someone reviews the file.
  • Phoenix serves them. The fuzzy flag is only a review hint; the possibly-wrong carried-over translation ships to users.
  • LinguiJS has the same problem in disguise. Its default message IDs are hashes of the source text, so editing copy orphans the translation just like classic gettext. Explicit IDs (id: "msg.header") are the opt-out, at the cost of naming everything yourself.

Either behavior is a bad default for a team that ships continuously. This is why CI auto-translation fits gettext even better than JSON: the format itself keeps producing stale and empty entries, and a pipeline that re-translates exactly those on every PR closes the gap the fuzzy heuristic leaves open.

The CI loop for .po files

The shape is the same as for any locale format: extraction stays local (mix gettext.extract --merge, django-admin makemessages, lingui extract), and CI picks up from the committed .po files. When a PR changes them, translate the new and changed msgids, fill in the msgstrs, and commit back before merge.

The .po-specific work is in the details a generic script gets wrong:

  • Plurals are indexed, not named. A message with msgid_plural carries msgstr[0], msgstr[1], and how many slots exist depends on the target language's Plural-Forms header: Swedish has 2, Russian 3, Arabic 6. Creating a new language file means generating that header correctly, not copying the source file's.
  • Context is part of the key. msgctxt lets one source string carry different translations; drop it on write-back and two distinct entries collapse into one.
  • Comments carry meaning. #: source references, #. extracted comments (Django's Translators: notes, Lingui's markers), and #, flags all need to survive a rewrite.
  • Domains stay separate. A Phoenix app has default.po and errors.po per locale; keys from one must never migrate into the other.

Localhero.ai, which we're building, covers the translation half of that loop. It doesn't replace your framework's extract-and-merge commands; it composes with them: you keep running mix gettext.extract or makemessages as usual, and the GitHub Action picks up the new and changed msgids on each PR and fills them with on-brand translations, handling the plural headers, contexts, comments, and domains above. How it compares to the alternatives, open source and hosted, is covered elsewhere.

Phoenix setup (including umbrella apps)

Phoenix keeps catalogs at priv/gettext/<locale>/LC_MESSAGES/<domain>.po, and the locale lives in the directory name, not the filename. Our CLI reads that convention directly, so a single-app project needs only:

{
  "schemaVersion": "1.0",
  "projectId": "your-project",
  "sourceLocale": "en",
  "outputLocales": ["sv", "de", "pt_BR"],
  "translationFiles": {
    "paths": ["priv/gettext"],
    "pattern": "**/*.po"
  }
}

For an umbrella app, point paths at each app's gettext root, or use a wildcard:

"translationFiles": {
  "paths": ["apps/*/priv/gettext"],
  "pattern": "**/*.po"
}

Domains are kept apart automatically: errors.po keys write back to each locale's errors.po, default.po to default.po. This isn't hypothetical, Naturkartan runs Localhero.ai on a Phoenix umbrella app in production.

Django and Lingui setup

Django's layout (locale/<lang>/LC_MESSAGES/django.po) follows the same LC_MESSAGES convention, so the config is the Phoenix one with "paths": ["locale"]. Keep compilemessages in your deploy step; the pipeline fills the .po files, Django still owns the .mo compile.

Lingui's .po catalogs put ICU MessageFormat inside the msgstr ({count, plural, one {...} other {...}}), so whatever translates them has to preserve ICU structure rather than fill gettext plural slots, and leave the js-lingui-explicit-id markers alone. Localhero.ai does both. If you're setting up Lingui itself, we cover that end to end in How to Localize a React App With Lingui.

What tends to break

From running this in production:

  • A one-string change fans out across the whole tree. New msgids land in default.po (and sometimes errors.po too), times every locale, times every app in an umbrella. What's one line in code becomes edits in a dozen files, and updating those by hand, or waiting for someone else to, is how translations end up blocking releases. Delta translation (only new and changed msgids) is what keeps the fan-out manageable.
  • Translations drift apart over time. A mature gettext tree carries years of contributions: an agency batch, a bilingual teammate, now an LLM. The same product term ends up translated three different ways across domains and languages, and nothing in the format notices. A glossary and translation memory applied to every new msgid is what keeps year-three files sounding like year-one.
  • Fuzzy entries need a policy. Decide whether your pipeline re-translates fuzzy entries or leaves them for humans. Leaving them means Django users see English and Phoenix users see the heuristic's guess.
  • Even specialized tools hit edges. Writing .po is harder than reading it: correct Plural-Forms headers per language, plural entries grouped with the right number of msgstr slots. We shipped a bug in June where newly created files came out with empty msgids; a customer's PR review caught it the same day, which is exactly what the review-before-merge loop is for. Whatever tool you use, eyeball the first PR that creates a new language.

Try it on one PR

Everything in that list is what Localhero.ai is built around: the changed msgids on each PR get translated with your glossary and tone, plural headers and contexts handled per language, and the result lands in a review UI where a PM or native speaker can check it before merge. Setup is quick with the CLI: run npx @localheroai/cli init in your project, point it at your gettext tree with the config above, and add the GitHub Action. It works with Django, Phoenix, and Lingui. If it doesn't fit, the comparison lists what else is out there.

FAQ

Can I auto-translate gettext .po files in CI? Yes. When a pull request changes a .po file, a CI step can translate the new and changed msgids with an LLM and commit the filled msgstrs back before merge. Localhero.ai does this as a hosted GitHub Action; i18n-ai-translate is an open-source action that handles .po files with your own API key.

What happens to translations when the source text changes? In classic gettext the source text is the key, so editing copy orphans its translations. The built-in answer is fuzzy matching, which carries the old translation over and flags it for review. Django skips fuzzy entries at compile time and falls back to English; Phoenix serves them as-is. An auto-translation step replaces that limbo with a fresh translation of the new text.

How do plural forms work when translating .po files? Each language's Plural-Forms header declares how many plural slots it uses: Swedish has 2, Russian 3, Arabic 6. A translation pipeline has to fill every slot the target language declares and generate a correct header when it creates a new language file, even when the source language has fewer forms.

Does this work with Phoenix umbrella apps? Yes. Point the configuration at each app's gettext directory or use a wildcard like apps/*/priv/gettext. Locales are read from the LC_MESSAGES directory convention, and each gettext domain file writes back to its own counterpart per locale.

Further reading

Ready to ship without translation delays?

No credit card required. Need help migrating? Just reach out.