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.
  • placeholders uses Python's %()s syntax 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 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 works automatically as long as you use ngettext (or {% blocktrans count %}). Localingos preserves all plural forms across CLDR rules.
  • .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 in localingos.json.

Free tier — 5,000 words is enough for most Django sites' string corpus.