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/cli
localingos login
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
localingos.json:
{
"projectId": "your-project-id",
"source": {
"path": "locale/en/LC_MESSAGES/django.po",
"locale": "en",
"format": "po"
},
"targets": {
"path": "locale/{locale}/LC_MESSAGES/django.po",
"locales": ["es", "de", "fr", "ja", "pt-br", "zh-hans", "ko"]
},
"placeholders": ["%(variable)s", "%(variable)d"]
}
Two Django-specific bits:
format: "po"— Localingos parses gettext .po files natively, preserving msgid/msgstr structure, plural forms, and comments.placeholdersuses Python's%()ssyntax since that's how Django interpolates inside translated strings.
localingos sync
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/cli
- 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 works automatically as long as you use
ngettext(or{% blocktrans count %}). Localingos preserves all plural forms across CLDR rules. .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 in localingos.json.
Free tier — 5,000 words is enough for most Django sites' string corpus.