Skip to content

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 msgctxt when you set it, and from msgid otherwise. Plain gettext identifies a string by its English text, so renaming English copy creates a new key. If you add msgctxt values 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 .po needs 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 ProjectsEditLocalesUpdate 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 committingpull 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 committingpull 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 .po is yours to own — preserve comments and plural forms there.
  • Django's %(name)s interpolation 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 makemessages before each sync. New {% trans %} tags only land in .po files after extraction.
  • Use gettext_lazy in 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.

  • .po and .mo files 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 (ProjectsEditLocales).

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.