# General Translation Knowledge Base: How do I translate PO and POT files?
URL: https://generaltranslation.com/en-US/kb/translate-po-files.mdx
Docs index: https://generaltranslation.com/llms.txt
Description: Fill every msgstr, including each plural form the Plural-Forms header asks for, and keep msgid, msgctxt, and placeholders unchanged. Run msgfmt --check for headers, plural counts, and placeholders on entries with a format flag, and check separately that no msgstr is empty. General Translation's CLI writes a translated PO file for each language from your POT template.
Category: Translation
Last updated: 2026-10-28
Authors: General Translation Team
Published: 2026-10-28

A POT (Portable Object Template) file holds the source strings extracted from your code, and each PO (Portable Object) file pairs those strings with one language's translations. Translating means filling in every `msgstr`, one per plural form the language uses, while `msgid`, `msgctxt`, and placeholders stay exactly as extracted. You then compile each PO file into the binary `.mo` catalog your program loads. The format is simple, but its failures are quiet: gettext shows the source text for anything it can't use.

## Where POT and PO files come from

Every gettext-based stack runs the same four steps under different command names:

| Step | GNU gettext | Babel (Flask, FastAPI) |
| --- | --- | --- |
| Extract source strings into a template | `xgettext` | `pybabel extract` |
| Start a PO file for a new language | `msginit` | `pybabel init` |
| Merge template changes into existing PO files | `msgmerge` | `pybabel update` |
| Compile a PO file to `.mo` | `msgfmt` | `pybabel compile` |

Django folds the first three into `makemessages`, which writes PO files directly, and compiles with `compilemessages`. Translation happens between the merge and the compile: the template has no language in it, so only PO files carry translations. (See [General Translation vs. GNU gettext](/kb/general-translation-vs-gettext) for the full gettext loop, including what `msgmerge` does when a source string changes.)

## What's in a PO entry

Here's the Polish PO file that General Translation's CLI wrote from a template `xgettext` extracted from a short Python script (header shortened):

```text title="po/pl.po"
msgid ""
msgstr ""
"Language: pl\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 == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2;\n"

#: inbox.py:8
#. TRANSLATORS: Heading of the inbox page.
msgid "Inbox"
msgstr "Skrzynka odbiorcza"

#: inbox.py:9
#, python-format
msgid "You have %(num)d unread message."
msgid_plural "You have %(num)d unread messages."
msgstr[0] "Masz %(num)d nieprzeczytaną wiadomość."
msgstr[1] "Masz %(num)d nieprzeczytane wiadomości."
msgstr[2] "Masz %(num)d nieprzeczytanych wiadomości."

#: inbox.py:10
msgctxt "email action"
msgid "Archive"
msgstr "Archiwizuj"
```

The [GNU gettext manual](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html) defines each part:

- **`msgid` and `msgid_plural`** are the source text, and `msgid` is also the lookup key. Never edit them in a PO file: a changed `msgid` matches nothing in the code, so users see the source text.
- **`msgstr`** is the translation, and it's the only line you write. A plural entry has `msgstr[0]` up to one less than the language's form count.
- **`msgctxt`** separates identical source strings with different meanings, such as "Archive" the email action versus an archive folder. It comes from `pgettext`, each context gets its own translation, and it stays as extracted.
- **`#.` extracted comments** are notes from developers (`xgettext --add-comments` pulls them from the code), **`#:` references** point to source lines, and **`# ` translator comments** belong to whoever translates.
- **`#,` flags** come in two kinds. `fuzzy` is a workflow flag meaning the translation may be wrong and needs a person to check it. Format flags such as `c-format` and `python-format` are set by `xgettext` and tell `msgfmt` to compare placeholders between source and translation.
- **The header** is the entry with an empty `msgid`. Its `Language`, `Content-Type` charset, and `Plural-Forms` fields apply to the whole file.

## How Plural-Forms decides the number of translations

`Plural-Forms` gives the number of forms (`nplurals`) and a C-like expression that maps a count `n` to a form index ([GNU gettext manual: plural forms](https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html)). With GNU gettext 1.0, `msginit` wrote `nplurals=1; plural=0;` for Japanese, `nplurals=2; plural=(n != 1);` for German, `nplurals=2; plural=(n > 1);` for French (where zero is singular), and three forms for Polish and Russian. Arabic needs six. For Arabic, the same `msginit` left the placeholder `nplurals=INTEGER; plural=EXPRESSION;` in the header, so check the header before anyone translates.

With the Polish file compiled, Python's `ngettext` picks a different slot by count:

```text title="Output"
count  slot        output
1      msgstr[0]   Masz 1 nieprzeczytaną wiadomość.
3      msgstr[1]   Masz 3 nieprzeczytane wiadomości.
5      msgstr[2]   Masz 5 nieprzeczytanych wiadomości.
22     msgstr[1]   Masz 22 nieprzeczytane wiadomości.
```

An English source has two forms, so translating it entry by entry into two Polish strings leaves `msgstr[2]` with nothing correct to show for 5 or 12.

## What breaks when you translate a PO file

These all compile or run without a crash, which is why they reach production:

- **Fuzzy entries never reach users.** `msgfmt` leaves them out of the `.mo` file unless you pass `--use-fuzzy`, which the manual calls "usually wrong." After marking the plural entry fuzzy, the Polish build printed "You have 5 unread messages." in English. Review each fuzzy entry, then remove the flag.
- **An empty plural slot can print nothing.** `msgfmt` compiled an entry with an empty `msgstr[2]` and counted it as translated, and Python printed an empty line for a count of 5. `--check` rejects it only when the entry has a format flag, because the empty slot then lacks the placeholder; without a flag, the entry passes.
- **Placeholders get dropped or renamed.** A translation without `%(num)d` loses the count. `msgfmt --check` catches it only on entries with a format flag.
- **Quotes and escapes are C-style.** A `"` inside `msgstr` must be written `\"`; an unescaped quote is a syntax error, and a line break inside a string is written `\n`.
- **The charset is a placeholder in templates.** `xgettext` writes `charset=CHARSET` in the POT header, and `msgfmt --check` refuses a PO file that still says that. `msginit` and `pybabel` write UTF-8; if a tool translates a copy of the template directly, run `msgconv --to-code=UTF-8` on the template first.

`msgfmt --check` reports the ones it can detect, and `--statistics` counts what made it into the build:

```text title="msgfmt --check"
$ msgfmt --check -o /dev/null missing-placeholder.po
missing-placeholder.po:30: a format specification for argument 'num' doesn't exist in 'msgstr[2]'
msgfmt: found 1 fatal error

$ msgfmt --check -o /dev/null two-forms.po
two-forms.po:7: nplurals = 3...
two-forms.po:30: ...but some messages have only 2 plural forms
msgfmt: found 1 fatal error

$ msgfmt --check --statistics -o inbox.mo fuzzy.po
2 translated messages, 1 fuzzy translation.
```

Run it in CI on every PO file, and treat any fuzzy count above zero as unfinished work.

No gettext tool reports a plural entry with one empty slot: `msgfmt --check` passes it when the entry has no format flag, and `msgattrib --untranslated` skips it. A short script with [Babel](https://babel.pocoo.org/) (`pip install babel`) fails the build on any empty translation:

```python title="check_po.py"
import sys

from babel.messages.pofile import read_po

failed = False
for path in sys.argv[1:]:
    with open(path, "rb") as f:
        catalog = read_po(f)
    for message in catalog:
        if not message.id or message.fuzzy:
            continue  # the header, and fuzzy entries msgfmt already skips
        forms = message.string if isinstance(message.string, tuple) else (message.string,)
        if not all(forms):
            print(f"{path}:{message.lineno}: empty translation for {message.id!r}")
            failed = True
sys.exit(1 if failed else 0)
```

```text title="Output"
$ python check_po.py po/pl.po
po/pl.po:17: empty translation for ('You have %(num)d unread message.', 'You have %(num)d unread messages.')
```

## Ways to translate PO files

| Approach | Who translates | Plural slots | Context the translator gets | When the template changes |
| --- | --- | --- | --- | --- |
| Edit by hand or in [Poedit](https://poedit.com/features/) | You or a translator | Poedit flags incorrect plural forms and missing tokens | Comments and references in the file | Run `msgmerge`, then review fuzzy entries |
| [Weblate](https://docs.weblate.org/en/latest/formats/gettext.html) or another translation management system (TMS) | Translators, with optional machine suggestions | Supported; fuzzy maps to "Needs editing" | Comments, references, and the TMS's screenshots or glossary | Its msgmerge add-on or repository sync updates the PO files |
| GNU gettext's `msgpre` | A local LLM through ollama, as a draft | Drafted for a translator to review | What the PO file carries | Rerun on new entries ([gettext 1.0 pretranslation](https://www.gnu.org/software/gettext/manual/html_node/Pretranslating.html)) |
| A script over a machine translation API or LLM | The model | Your script must emit every slot the header calls for | Whatever your script sends | Rerun and validate the output yourself |
| [General Translation's CLI](/docs/cli/reference/formats/po-pot-files) | General Translation's translation service | Every slot for the target language, checked for placeholders | `msgctxt`, comments, references, flags, plus your Glossary and Custom Prompts | Reuses unchanged translations and translates new or changed entries |

Poedit's machine-translated pre-translation is a Pro feature; editing, translation memory, and the checks are in the free version. (See [General Translation vs. Weblate](/kb/general-translation-vs-weblate) for how a TMS round trip compares.)

Use Poedit when you translate one or two languages yourself. Use Weblate or another [TMS](/kb/what-is-a-translation-management-system) when professional translators own the catalogs and need assignments and review queues. Use a script or `msgpre` only if you'll own the validation. Use General Translation when PO files should be translated for every language as part of each build, with terms shared across your other products.

## Translate PO files with General Translation

General Translation's CLI, `gt`, reads a POT (or source-language PO) file and writes a translated PO file for each locale. Add a `pot` entry to `gt.config.json`. This config was checked against `gt` 2.26.11, and the CLI's own path resolution maps `po/messages.pot` to `po/de.po`, `po/pl.po`, and `po/pt_BR.po`:

```json title="gt.config.json"
{
  "defaultLocale": "en",
  "locales": ["de", "pl", "pt_BR"],
  "customMapping": { "pt_BR": { "code": "pt-BR" } },
  "files": {
    "pot": {
      "include": ["po/messages.pot"],
      "transform": { "match": "^po/messages\\.pot$", "replace": "po/{locale}.po" },
      "transformationFormat": "PO"
    }
  }
}
```

- **`pot`** reads both PO and POT files (`POT` works too). PO and POT support arrived in `gt` 2.14.22.
- **`"transformationFormat": "PO"`** is required. Without it, the output stays an untranslated template.
- **[`transform`](/docs/cli/reference/config#locale-placeholders)** names each output file; `{locale}` is the locale as written in `locales`, and [`customMapping`](/docs/cli/reference/config#custom-mapping) ties gettext's `pt_BR` to the standard code `pt-BR`.
- **Babel and Django layouts** skip `transform`: point `include` at the English catalog with a `[locale]` placeholder in its folder, such as `[locale]/LC_MESSAGES/*.po` under `translations/`, and the CLI writes each language's catalog beside it, such as `pl/LC_MESSAGES/messages.po`.

Then run the CLI between extraction and compilation:

```bash
xgettext --language=Python --keyword=pgettext:1c,2 --add-comments=TRANSLATORS -o po/messages.pot app.py
msgconv --to-code=UTF-8 -o po/messages.pot po/messages.pot
npx gt translate
msgfmt --check -o locale/pl/LC_MESSAGES/messages.mo po/pl.po
```

Set up credentials first with [`npx gt login`](/docs/cli/reference/commands/login) and a project ID, or a project API key in CI ([CLI credentials](/docs/cli/guides/configuring#credentials)). If your PO files already hold reviewed translations, import them with [`npx gt upload`](/docs/cli/reference/commands/upload) before the first [`npx gt translate`](/docs/cli/reference/commands/translate).

For each entry, General Translation translates the `msgid` text, sending its `msgctxt`, `#.` comments, `#:` references, and flags along as context. It sets the output's `Language` header and, when the file has plural entries, writes the target language's `Plural-Forms` rule and fills every `msgstr[n]` that rule calls for: three for Polish, six for Arabic. Each plural translation is checked for empty slots and for placeholders such as `%(num)d`, `%s`, or `{name}` missing from any form. Comments, references, flags, and `msgctxt` carry over from the template, and unchanged entries reuse their earlier translations on the next run.

Because the source is the `msgid`, this works best when your code uses source text as the key, which is gettext's default. Reviewers can [edit entries in the Dashboard](/docs/platform/dashboard/guides/reviewing-translations), and a Glossary and Custom Prompts in a [Context Group](/docs/platform/dashboard/guides/defining-context-for-translations) keep a term like "Archive" consistent with your web app and docs. If someone edits a PO file locally, sync it with [`npx gt save-local`](/docs/cli/reference/commands/save-local) before the next run. For framework-specific setups, see [General Translation vs. Django's translation framework](/kb/general-translation-vs-django-i18n), [General Translation vs. Babel and Flask-Babel](/kb/general-translation-vs-flask-babel), and [How do I translate a Flask or FastAPI app?](/kb/translate-python-app).

## Common questions

### What's the difference between a PO and a POT file?

A POT file is the template: every `msgstr` is empty and the header has no language. A PO file is a copy for one language, with a `Language` header, that language's `Plural-Forms` rule, and the translations.

### Do I still need msgmerge if General Translation writes the PO files?

Not for the target languages when the POT template is the source. Each run writes every language's PO file from the current template, so entries removed from the code drop out and new ones arrive translated, with no fuzzy entries to clear. Babel and Django layouts still update the English catalog with `pybabel update` or `makemessages`.

### Why does my app still show English after I translated the PO file?

Usually one of three things: the entry is still marked `fuzzy`, the `.mo` file wasn't recompiled or redeployed, or the `msgid` in the PO file no longer matches the string in the code. `msgfmt --statistics` shows how many entries are translated and fuzzy.

## Key takeaways

- Translate only `msgstr`; `msgid`, `msgid_plural`, and `msgctxt` are lookup keys and stay as extracted.
- Write as many `msgstr[n]` slots as the header's `nplurals` says: one for Japanese, two for German, three for Polish, six for Arabic.
- `msgfmt` drops fuzzy entries, and it compiles an empty plural slot that prints nothing; `--check` catches that only on entries with a format flag, so run `msgfmt --check --statistics` and a separate check for empty translations on every PO file.
- General Translation's CLI translates a POT template into a PO file per locale with `"pot"`, `"transformationFormat": "PO"`, and a `transform`, filling every plural slot for the target language.

## Related questions

- [General Translation vs. GNU gettext: do you need both?](https://generaltranslation.com/en-US/kb/general-translation-vs-gettext.mdx): Keep GNU gettext for extraction and rendering, and add General Translation to generate and maintain the PO files. General Translation carries translator notes and message context into generation, applies shared terminology, and gives reviewers a Dashboard editor. Your existing gettext calls and compiled catalogs stay in place.
- [General Translation vs. Django's translation framework: do you need both?](https://generaltranslation.com/en-US/kb/general-translation-vs-django-i18n.mdx): Use Django's translation framework and General Translation together. Django handles message extraction, language selection, and rendering. General Translation fills its PO files, including plural forms, with your product terminology and Dashboard review. Your templates and URL patterns stay the same while translations update with your code.
- [General Translation vs. Babel and Flask-Babel: which should you use?](https://generaltranslation.com/en-US/kb/general-translation-vs-flask-babel.mdx): Babel formats dates, numbers, and currencies for any locale and manages gettext catalogs, and Flask-Babel connects it to Flask, but neither translates your strings. General Translation's CLI fills in Babel's PO files for every language, plural forms included, so you can keep Flask-Babel and stop translating catalogs by hand.
- [How do I translate a Flask or FastAPI app?](https://generaltranslation.com/en-US/kb/translate-python-app.mdx): Mark user-facing strings, pick each request's locale (usually from Accept-Language), and load that locale's translations. Flask apps usually use Flask-Babel with gettext catalogs, and FastAPI apps wire Babel into a middleware. General Translation's CLI fills those catalogs, and its experimental gt-flask and gt-fastapi SDKs translate inline t() strings without catalogs.
- [General Translation vs. Weblate: which should you use?](https://generaltranslation.com/en-US/kb/general-translation-vs-weblate.mdx): Weblate is a self-hostable, Git-based localization platform built for translating repository files with a community or team. General Translation integrates translation generation into development and releases, using marked JSX and shared product context. Keep your gettext files or adopt inline authoring to remove source-catalog maintenance too.

## Previous and next questions

- Previous question: [How do I translate Lottie animations?](https://generaltranslation.com/en-US/kb/translate-lottie-animations.mdx)
- Next question: [How do I translate SEO titles and meta descriptions?](https://generaltranslation.com/en-US/kb/translate-seo-metadata.mdx)

## Sitemap

See the [Knowledge Base sitemap](https://generaltranslation.com/en-US/kb/sitemap.md) for every article.
