Localize a Django app: complete guide with Localingos
Django's i18n system is mature and battle-tested — it's been shipping in production for two decades. The pain point isn't the runtime; it's the workflow of keeping .po files current and translated as English copy evolves. This guide pairs Django's built-in gettext-based i18n with Localingos to automate the translation step while keeping the standard .po file workflow intact.
Step 1 — Install
pip install django # if not already
npm install -g localingos
Django ships with i18n built-in — no extra Python packages needed.
Step 2 — Enable i18n in settings
settings.py:
LANGUAGE_CODE = 'en'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
LANGUAGES = [
('en', 'English'),
('es', 'Español'),
('de', 'Deutsch'),
('fr', 'Français'),
('ja', '日本語'),
('pt-br', 'Português (Brasil)'),
('zh-hans', '中文 (简体)'),
('ko', '한국어'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # add this
'django.middleware.common.CommonMiddleware',
# ...
]
The LocaleMiddleware detects user language from the URL prefix, then Accept-Language header, then session. Standard pattern.
Step 3 — Mark strings for translation
In templates:
{% load i18n %}
<h1>{% trans "Welcome" %}</h1>
<p>{% blocktrans with name=user.name %}Hello, {{ name }}!{% endblocktrans %}</p>
{% blocktrans count counter=cart_items %}{{ counter }} item in cart{% plural %}{{ counter }} items in cart{% endblocktrans %}
In Python:
from django.utils.translation import gettext as _, ngettext
def view(request):
msg = _('Welcome')
cart_msg = ngettext(
'%(count)d item in cart',
'%(count)d items in cart',
item_count,
) % {'count': item_count}
return render(request, 'home.html', {'msg': msg, 'cart_msg': cart_msg})
Step 4 — Extract to .po files
Django's built-in command scans your code/templates and generates one .po file per locale:
django-admin makemessages --all
This creates locale/en/LC_MESSAGES/django.po, locale/es/LC_MESSAGES/django.po, etc. — the en one is your source of truth.
Step 5 — Configure Localingos
Run localingos init and answer its prompts. It writes two files.
localingos.config.json — commit this. Project settings shared with your team and CI:
{
"projectId": "your-project-id",
"sourceLocale": "en-US",
"format": "po",
"sourceFile": "locale/en/LC_MESSAGES/django.po",
"outputDir": "locale",
"outputPattern": "{locale}/LC_MESSAGES/django.po"
}
.localingos.json — add this to .gitignore. Your API key for local development:
{ "apiKey": "your-api-key" }
In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.
The CLI reads and writes gettext .po directly, so there is no conversion step: your catalogue stays in the format your framework already expects.
Things worth knowing:
- Keys come from
msgctxtwhen you set it, and frommsgidotherwise. Plain gettext identifies a string by its English text, so renaming English copy creates a new key. If you addmsgctxtvalues your ids stay stable across copy edits — worth doing before your catalogue grows. - Translator comments round-trip. A
#.comment above an entry is sent as that string's description and comes back on the generated catalogues, so.poneeds no descriptions sidecar. - Plural entries are left alone.
msgid_plural/msgstr[n]blocks are skipped and reported rather than flattened, because translation cannot add the plural categories a target language needs. Keep owning those in your own catalogue. - Target locales are not configured here. They belong to the project — set them in the dashboard under Projects → Edit → Locales → Update Project. Codes are full BCP 47, e.g.
es-ES,pt-BR,el-GR. - Placeholder preservation is automatic. Localingos extracts the placeholders in your source string and validates each one survives translation, retrying with a corrective prompt when it doesn't.
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation
No new translations. Run "localingos sync" again later.
That's expected. Run it again shortly to collect results. Verify completeness before committing — pull writes whatever is ready and exits 0, so a catalogue pulled mid-translation can be missing keys with no warning.
Then push your source strings and pull back translations:
localingos sync
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation
No new translations. Run "localingos sync" again later.
That's expected. Run it again shortly to collect results. Verify completeness before committing — pull writes whatever is ready and exits 0, so a catalogue pulled mid-translation can be missing keys with no warning.
Two Django-specific bits:
- Keep msgid/msgstr structure in your own conversion step. The CLI is JSON-only (see the notice at the top), so the round-trip through
.pois yours to own — preserve comments and plural forms there. - Django's
%(name)sinterpolation is preserved automatically. Placeholder detection is server-side and needs no configuration.
Translations land in each locale's .po file. Then compile to .mo (Django reads .mo at runtime):
django-admin compilemessages
Step 6 — URL routing per locale
urls.py:
from django.conf.urls.i18n import i18n_patterns
from django.urls import path
from . import views
urlpatterns = i18n_patterns(
path('pricing/', views.pricing, name='pricing'),
path('docs/', views.docs, name='docs'),
prefix_default_language=False, # English at /, others at /es/, /de/
)
Now /pricing is English, /es/pricing is Spanish, automatically. Same SEO-friendly URL structure as the Next.js / Nuxt setups.
Step 7 — hreflang and sitemap
# views.py
from django.utils.translation import get_language
def page_metadata(request, route):
base = 'https://example.com'
return {
'canonical': f'{base}{route}',
'hreflang': [
(lang_code, f'{base}/{lang_code}{route}' if lang_code != 'en' else f'{base}{route}')
for lang_code, _ in settings.LANGUAGES
],
}
In the template:
<link rel="canonical" href="{{ meta.canonical }}">
{% for code, url in meta.hreflang %}
<link rel="alternate" hreflang="{{ code }}" href="{{ url }}">
{% endfor %}
For sitemap, Django's django.contrib.sitemaps framework supports per-locale entries via i18n = True on a sitemap class.
Step 8 — Automate sync in CI
# .github/workflows/i18n.yml
name: i18n-sync
on:
push: { branches: [main], paths: ['locale/en/LC_MESSAGES/django.po'] }
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install -g localingos
- run: localingos sync
env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
- run: pip install django && django-admin compilemessages
- uses: peter-evans/create-pull-request@v6
with:
branch: i18n/auto-sync
title: 'chore(i18n): sync translations'
commit-message: 'chore(i18n): sync translations + compile .mo'
The compilemessages step recompiles .po → .mo so the new translations are deploy-ready.
Production checklist
- Re-run
makemessagesbefore each sync. New{% trans %}tags only land in.pofiles after extraction. - Use
gettext_lazyin module-level code. Code that runs at import time (forms, models) needs lazy translation, otherwise strings get bound to whatever locale was active at import. - Pluralization needs your source to carry every form. Use
ngettext(or{% blocktrans count %}) as usual, but note that translation preserves the number of forms you send rather than expanding them to each language's CLDR categories.
Plurals need a deliberate decision. Localingos translates the forms you send it. It does not add plural categories your source language doesn't have — a string with two forms comes back with two forms, even in a language that needs four (Polish) or six (Welsh). Author every form your target languages require in your source file and let your i18n library select among them at runtime, or keep count-bearing copy out of translation and format numbers separately.
.poand.mofiles belong in git so deployed environments don't need a sync step. CI handles updates.
Wrap up
Django's built-in i18n + automated translation = a production-grade multilingual app with locale-prefixed URLs, hreflang, and zero manual translation work. Adding a locale is one entry in LANGUAGES and one change on the project in the dashboard (Projects → Edit → Locales).
Free tier: 5,000 words, counted once per target locale — so a small Django site in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.