Django

Localhero.ai translates Django gettext .po files on every pull request. New and changed strings are translated and committed back to the same PR, so you always have on-brand translations ready to deploy with each PR.

Prerequisites

  • A Django project with gettext catalogs (locale/ or similar)
  • Node 18 or later, to run the CLI through npx
  • A Localhero.ai API key

Get an API key from your account.

Setup

1. Initialize the project

Run the CLI in your project root. No install step, and it detects Django from your manage.py:

npx @localheroai/cli init

For a detected Django project it proposes the **/*.{po,pot} pattern and the django workflow, and looks for your catalog directory among locale/, locales/ and translations/, taking the first that exists. A project with none of them yet is offered translations/. A directory only counts if it actually holds <lang>/LC_MESSAGES/*.po files, so an unrelated app or package with one of those names is skipped. Per-app <app>/locale/ layouts are not found automatically, so point --path at them during init, or edit translationFiles.paths afterwards.

The pattern includes .pot because that file is your source catalog, covered under "The source catalog" below. Expect init to offer to create it if you have never kept one.

2. Check the generated config

init writes a localhero.json in your project root. For a Django project with catalogs in locale/ it comes out like this:

localhero.json

{
  "schemaVersion": "1.0",
  "projectId": "your-project-id",
  "sourceLocale": "en",
  "outputLocales": ["sv", "de", "es"],
  "translationFiles": {
    "paths": ["locale/"],
    "pattern": "**/*.{po,pot}",
    "workflow": "django"
  }
}

Commit this file. Fields are documented in Project Setup.

3. Add the GitHub Action workflow

init offers to create the workflow for you, running localheroai/localhero-action. Add your API key as the repository secret LOCALHERO_API_KEY on GitHub.

The generated workflow includes a makemessages step before the Localhero.ai step. Without it the job would run green and translate nothing, because only strings already in your catalogs are visible to it. It looks like this:

Adjust the paths: filter if your .po files live somewhere other than the config above:

.github/workflows/localhero-translate.yml

name: Localhero.ai - Automatic I18n translation

on:
  pull_request:
    paths:
      - "locale/**"
      - "**/*.py"
      - "**/*.html"
      - "**/*.txt"
      - "localhero.json"
  repository_dispatch:
    types: [localhero-sync]
  workflow_dispatch:

concurrency:
  group: translate-${{ github.event.client_payload.branch || github.head_ref || github.run_id }}
  cancel-in-progress: true

jobs:
  translate:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          ref: ${{ github.event.client_payload.branch || github.head_ref || github.ref_name }}
          fetch-depth: 0

      - uses: actions/setup-python@v5
        if: github.event_name == 'pull_request'
        with:
          python-version: "3.12"

      - name: Extract messages
        if: github.event_name == 'pull_request'
        run: |
          sudo apt-get install -y -qq gettext
          pip install -r requirements.txt
          python manage.py makemessages --keep-pot -l sv -l de -l es

          # Optional: makemessages rewrites the creation-date header on every run,
          # so without this line every CI run shows a one-line diff in every catalog.
          git ls-files --modified --others --exclude-standard -z -- '*.po' '*.pot' | xargs -0 -r sed -i '/^"POT-Creation-Date: /d' --

      - name: Translate
        uses: localheroai/localhero-action@v1
        with:
          api-key: ${{ secrets.LOCALHERO_API_KEY }}

fetch-depth: 0 is important: the Action diffs against the base branch, and a shallow checkout has nothing to compare. See the GitHub Actions guide for all inputs.

The extract step means a PR that only touches templates or Python code still gets its new strings translated. Your target languages are named explicitly rather than using --all, which only picks up locales that already have a directory and would silently write nothing on a fresh project.

--keep-pot is what keeps your source catalog current: the .pot is rebuilt from your code on every PR, so your source strings can never drift. More on that file below if you have not kept one before.

The sed line is optional. makemessages rewrites the POT-Creation-Date header on every run, so without it every CI run shows a one-line diff in every catalog even when no string changed. Drop it if you would rather keep the header.

Two things to check: the step installs dependencies with pip install -r requirements.txt and Python 3.12, so adjust both if you use a different version. init reads your lockfile, so a project with uv.lock, poetry.lock or Pipfile.lock gets that tool set up and used instead. Extraction is skipped on sync runs, where Localhero.ai writes reviewed translations back. Prefer extracting locally before each PR? Drop the step and the source-file triggers.

How it works

  1. You open a pull request that touches your templates, Python code or catalogs.
  2. The workflow runs makemessages to extract new strings into the catalogs, then the GitHub Action diffs the branch against your base branch and translates only the messages that changed or are still untranslated. Updated and removed messages are synced to the Localhero.ai backend.
  3. It commits the updated .po files to the same PR, so the diff you review contains both the code and its translations.
  4. You review and edit the translations in the dashboard, and edits sync back to the branch.
  5. The translated .po files still need python manage.py compilemessages to become the .mo files Django serves. Most projects already run it during build or deploy. If yours does not, add it to your deploy step or to this workflow after the translation step.

Nothing in your app has to know Localhero.ai exists. The .po catalog is the integration, so your gettext calls and template tags stay as they are.

Rather not automatically translate on PRs? Run makemessages --keep-pot then npx @localheroai/cli translate locally to fill in every missing message, and commit the result yourself. See the CLI reference.

Longer walkthrough with a real codebase: localizing a Django app. For the CI side on its own, see translating .po files in CI.

Django and .po specifics

The source catalog

Localhero.ai needs to know what your source strings currently are. Anything that already serves that purpose works:

  • A committed locale/en/LC_MESSAGES/django.po, which many projects have because someone ran makemessages -l en or the project extracts every language in settings.LANGUAGES. Nothing to do.
  • A committed .pot, if you already keep one. Also nothing to do.

If you have neither, read on. Otherwise skip to the next section.

A stock Django project has no source catalog, because English lives in your gettext() calls and {% translate %} tags rather than in a file. makemessages gathers them into a .pot, merges it into each target catalog, then deletes it again.

Two things make it stick, and init sets up both:

  1. Keep it. Pass --keep-pot to makemessages. The generated workflow already does.
  2. Commit it. Treat locale/django.pot like any other catalog, and do not gitignore it. The Action compares against the base branch, and a file missing there looks like every string is new.

Run plain makemessages locally after that and Django deletes the template again; committing that deletion makes the next CI run re-translate from scratch. A Makefile target is the usual fix.

When init finds no source catalog it offers to run the extraction for you. It defaults to no and runs nothing but that one command, and declining prints the command for you to run yourself.

Both files can coexist. The .pot takes precedence, and the difference is that it is rebuilt from your code on every run while a hand-kept English catalog only updates when you remember to ask.

Locale directory layout

The gettext layout Django uses, <locale>/LC_MESSAGES/<domain>.po, works without configuration. The .po filename is the domain, not the language, so the directory above LC_MESSAGES is read as the locale.

Non-standard codes are trusted too: sv_FI_custom/LC_MESSAGES/django.po is read as sv_FI_custom, no localeRegex needed.

Excluding messages

Exclusions work per file rather than per message. Use translationFiles.ignore with a glob to leave a catalog alone.

For a specific set of messages, keep them in their own domain or directory and exclude that path.

Renaming and removing messages

In the pull request flow this takes care of itself: messages removed in the diff are reported to the backend along with the rest of the changes. The manual push command is more conservative and never deletes, so after rewording or removing messages outside a PR, run push --prune --force to make the remote side match your files.

This comes up more in Django than in key-based formats, because the msgid is the source string itself. Editing English copy creates a new message rather than updating an existing one.

Plurals

Plurals are indexed, not named: each target language's own Plural-Forms header decides how many msgstr[N] slots it needs, not a copy of the source language's count. Polish gets four slots (msgstr[0]-msgstr[3]) even when your English source only distinguishes singular from plural.

Questions we get

Can I translate some languages myself?

Yes. Turn off Auto-translate for that language in your project settings, and Localhero.ai writes nothing for it. The CLI reports those languages as skipped, and you keep them under your own process while the rest are translated automatically.

Will it translate my whole catalog on every PR?

No, only messages that changed or are missing. On a pull request the diff is against the PR's base branch. Locally, --changed-only compares against main unless translationFiles.baseBranch says otherwise.

How do I stop it running on a specific PR?

Add the skip-translation label. The Action also skips draft PRs and its own commits. The details are in the GitHub Actions guide.

Last updated

Ready to try it?

Get setup in a couple of minutes. No credit card required.