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 pattern and the django workflow, and looks for your catalog directory among locale/, locales/ and 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.
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",
"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@v5
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 -l sv -l de -l es
- 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.
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
- You open a pull request that touches your templates, Python code or catalogs.
- The workflow runs
makemessagesto 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. - It commits the updated
.pofiles to the same PR, so the diff you review contains both the code and its translations. - You review and edit the translations in the dashboard, and edits sync back to the branch.
- The translated
.pofiles still needpython manage.py compilemessagesto become the.mofiles 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 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
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 three slots (msgstr[0]-msgstr[2]) 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.