GNU gettext dates from 1990 and still underpins a substantial share of free software localization — GNOME, KDE, WordPress, Django, Drupal and thousands of smaller projects. It is old, it has sharp edges, and it does one thing genuinely well: it separates the extraction of strings from their translation, and both from the running program.

This is the whole pipeline, in order, including the parts that reliably cause trouble.

Three files, three jobs

FileWhat it isWho touches it
.potPortable Object Template — every source string, no translationsGenerated by tooling; never edited by hand
.poOne catalogue per locale, source plus translation, plain textTranslators, via an editor or platform
.moCompiled binary catalogue, hash-indexed for fast lookupGenerated at build time; never committed

The flow runs one way: source code → POT → PO (per locale) → MO (per locale) → running program. Every arrow is a command, and every command belongs in CI rather than in someone's shell history.

Step one: extracting the template

xgettext walks your sources looking for calls to the translation functions and writes out a template.

xgettext \
  --language=Python \
  --keyword=_ --keyword=N_ \
  --keyword=pgettext:1c,2 \
  --keyword=ngettext:1,2 \
  --from-code=UTF-8 \
  --add-comments=Translators: \
  --package-name="Example" \
  --package-version="2.4" \
  --msgid-bugs-address="[email protected]" \
  --output=po/example.pot \
  $(find src -name "*.py" | sort)

Four flags deserve attention:

  • --add-comments=Translators: pulls source comments beginning with that marker into the catalogue. This is how context reaches translators; without it, they work blind.
  • --keyword=pgettext:1c,2 teaches xgettext that the first argument is a context string, not a translatable one. Get this wrong and your contexts appear as strings to translate.
  • --keyword=ngettext:1,2 registers both the singular and plural forms of a countable message.
  • Sorting the file list matters. Unsorted input produces a POT whose line references shuffle on every run, generating meaningless diffs.

The stale template problem

The most common gettext failure is a POT that no longer matches the source. Fix it structurally: run extraction in CI and fail the build if the result differs from what is committed. One line of pipeline configuration removes an entire class of "why is this string not translated" tickets.

Step two: reading a PO file

A PO file is plain UTF-8 text, which is a large part of why gettext has lasted. A typical entry:

#. Translators: shown when the upload exceeds the size limit
#: src/upload.py:142
#, python-format
msgid "File %(name)s is too large."
msgstr "Файл %(name)s завеликий."

The comment lines carry the meaning:

  • #. — extracted source comment, written by developers for translators.
  • #: — source references. Useful for jumping to context; also the reason unsorted extraction produces noisy diffs.
  • #, — flags. python-format tells tooling to validate placeholders; fuzzy means "carried over, unconfirmed".
  • #| — the previous msgid, kept when a string changed, so the translator can see what changed.

The header entry, with an empty msgid, holds the metadata. The critical field is Plural-Forms:

msgid ""
msgstr ""
"Language: uk\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : "
"n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"

That expression is evaluated at runtime to choose which plural form to use. A wrong Plural-Forms header produces subtly incorrect grammar across an entire locale and is invisible to anyone who does not speak the language — which is precisely why it should be validated automatically rather than trusted.

Step three: plural entries

A countable string uses msgid_plural and indexed msgstr slots — as many as the locale's nplurals declares:

msgid "%d file selected"
msgid_plural "%d files selected"
msgstr[0] "Вибрано %d файл"
msgstr[1] "Вибрано %d файли"
msgstr[2] "Вибрано %d файлів"

English supplies two forms because that is all English has. The translator supplies however many their language needs. In code, always call ngettext with the count — never select the form yourself:

# Correct — gettext applies the locale's plural rule
msg = ngettext("%d file selected", "%d files selected", n) % n

# Wrong — hard-codes English grammar into every locale
msg = _("%d file selected") if n == 1 else _("%d files selected") % n

Step four: merging updates into existing catalogues

When the source changes, you do not regenerate PO files — that would discard every translation. You merge the new template into each existing catalogue with msgmerge:

for po in po/*.po; do
  msgmerge --update --backup=none --previous "$po" po/example.pot
done

msgmerge does three things: it adds new strings as untranslated, it comments out strings that no longer exist (preserving them for future reuse), and it attempts fuzzy matching for strings that changed slightly. --previous records the old msgid so translators can see the difference.

Step five: understanding fuzzy entries

Fuzzy is the most misunderstood part of gettext, and the misunderstanding causes real bugs.

#, fuzzy
#| msgid "Delete this file?"
msgid "Delete these files?"
msgstr "Видалити цей файл?"

Here the source changed from singular to plural. msgmerge carried the old translation across and flagged it fuzzy. The translation is now wrong — it still says "this file". Two rules follow:

  1. Fuzzy entries are not used at runtime. gettext falls back to the source string. This is deliberate: showing the source is safer than showing a translation that may now be incorrect.
  2. Fuzzy means "a human must look". Bulk-unfuzzying a catalogue to raise the completion percentage is one of the most damaging things anyone can do to a translation, and we have seen it done by well-meaning people chasing a green progress bar.

Ship with zero fuzzy entries in production locales. Check it in CI:

msgattrib --only-fuzzy --no-obsolete po/uk.po | grep -q msgid \
  && { echo "Fuzzy entries present"; exit 1; } || true

Step six: compiling and installing

msgfmt turns a PO file into the binary MO the runtime loads. Always compile with --check:

msgfmt --check --statistics \
  --output-file=locale/uk/LC_MESSAGES/example.mo \
  po/uk.po

--check validates the header, verifies plural form counts and — importantly — compares format specifiers between msgid and msgstr. A translation that drops %(name)s or adds a stray %d is a runtime crash waiting to happen, and this flag catches it at build time.

MO files go in {localedir}/{lang}/LC_MESSAGES/{domain}.mo. They are build artefacts: generate them in CI, ship them in the package, and keep them out of version control.

Step seven: continuous localization

Manual extraction happens once, then is forgotten until a week before release. Put the whole cycle in CI and it stops being anybody's job to remember:

# .github/workflows/i18n.yml (abridged)
jobs:
  extract:
    steps:
      - run: make pot
      - run: git diff --exit-code po/example.pot ||
                { echo "POT is stale — run 'make pot'"; exit 1; }

  validate:
    steps:
      - run: |
          for po in po/*.po; do
            msgfmt --check --output-file=/dev/null "$po" || exit 1
          done
      - run: pofilter --gnome -t printf -t variables po/ /tmp/errors
      - run: "! find /tmp/errors -name '*.po' -size +0"

Three gates, each catching a different class of problem: a stale template, a malformed or placeholder-broken catalogue, and format inconsistencies that msgfmt alone will not spot. Everything else — actual translation — happens on the platform, and syncs back as ordinary commits.

Ordering matters

We once shipped a pipeline that extracted before the merge commit landed, so translators consistently saw the previous release's strings. It took a month to notice. Extraction must run after merge to the release branch, not on the pull request.

Seven things that reliably go wrong

  1. Translating the empty string. _("") returns the catalogue header, not an empty string. Guard against empty input before calling gettext.
  2. Wrong domain or localedir. Silent fallback to English is gettext's failure mode. If nothing is translating and the catalogue looks fine, check bindtextdomain first — it is nearly always that.
  3. Unsorted extraction. Produces line-reference churn on every run and turns POT diffs into noise nobody reads.
  4. Concatenation. gettext cannot save you from it. Fragments are untranslatable regardless of toolchain, as covered in the i18n foundations guide.
  5. Committing MO files. Binary artefacts in version control cause merge conflicts nobody can resolve and go stale against their PO source.
  6. Locale not actually installed. On some systems setlocale silently fails if the system locale is absent, and everything falls back to C. Assert that setlocale returned what you asked for.
  7. Mixed encodings. Everything should be UTF-8, everywhere, declared in the header and enforced by --from-code=UTF-8.

When gettext is the wrong tool

We use gettext heavily and still recommend against it in three cases. Being honest about them saves everyone a migration later.

  • Rich grammatical selection. gettext handles plurals but has no native concept of gender or arbitrary selectors. ICU MessageFormat or Fluent handle these directly; with gettext you end up with context hacks that translators find bewildering.
  • Web front ends. Loading a binary MO in a browser is awkward. JSON catalogues generated from PO, or a native JavaScript format, fit the deployment model far better.
  • Content that is documents rather than strings. Articles, help pages and marketing copy are better handled as whole files in Markdown or XLIFF than as thousands of paragraph-sized msgids.

Where gettext remains excellent: application interfaces with stable string sets, projects with an existing translator community that already knows PO files, and anything where a plain-text, diffable, tool-agnostic catalogue format matters more than expressiveness. That last property is genuinely underrated — a PO file from 2003 opens today in any editor, which is not something you can say about most formats of that vintage.


Working on something like this?

We do this for open source projects for a living, and for free when the project cannot pay. Tell us about yours — the first audit costs nothing.