Django Advanced

Internationalization and Localization in Django at Scale: i18n, l10n, Time Zones, and Translated Content

Do multilingual Django properly: marking strings, the message-file workflow, pluralization, URL-based language selection, locale-aware formatting, time zones, translating database content (not just the UI), RTL, and running translations across a real team.

KC Ramo · DjangoZen Team Jul 11, 2026 18 min read 346 views

Adding a second language to a Django app is deceptively easy to start and full of traps at scale — lazy translations that break migrations, dates that display in the wrong format, model content that the translation system never touches, and a workflow that collapses once real translators are involved. This tutorial covers internationalization and localization in Django properly: marking strings, the message-file workflow, locale-aware formatting, time zones, translating database content, and running translations across a real team.

i18n versus l10n

Two related but distinct jobs hide behind "make it multilingual". Internationalization (i18n) is preparing your code so it can be translated and localized — marking strings, avoiding hard-coded formats. Localization (l10n) is the actual adaptation for a locale — the translated text, the local date and number formats, the currency. Django handles both, but you do the i18n work in code once and the l10n work per locale ongoing. Confusing them leads to apps that are "translated" but still show 12/31/2025 to someone who writes 31/12/2025.

Marking strings for translation

Django cannot translate a string it does not know is translatable. You mark them with gettext (conventionally imported as _) in Python and template tags in templates.

from django.utils.translation import gettext as _

def greet(request):
    message = _("Welcome to our shop")
    return render(request, "home.html", {"message": message})

The rule is to mark every user-facing string and only user-facing strings — do not mark log messages, internal keys, or format specifiers. Every unmarked UI string is a bug that silently stays in the source language.

Translating templates

In templates, {% translate %} handles single strings and {% blocktranslate %} handles text with variables or that spans markup.

{% load i18n %}
<h1>{% translate "Your cart" %}</h1>
{% blocktranslate with name=user.first_name %}
Hello {{ name }}, you have items waiting.
{% endblocktranslate %}

Use blocktranslate for anything with a variable rather than concatenating translated fragments — word order differs across languages, and gluing pieces together produces sentences that are grammatically broken in half your locales.

Lazy translation and its gotchas

At module level — model field labels, choices, form messages — the active language is not yet known when the code runs, so you use gettext_lazy, which defers translation until the string is actually rendered.

from django.utils.translation import gettext_lazy as _

class Product(models.Model):
    STATUS = [("draft", _("Draft")), ("live", _("Live"))]
    name = models.CharField(_("name"), max_length=200)

The classic trap: a lazy string is a proxy object, not a real string, so concatenating it or putting it somewhere that expects a plain str — notably migration files — breaks. Never bake a lazy translation into a migration; migrations are frozen historical records and must contain literal strings.

The message-file workflow

Translations live in .po files, one per language, generated and compiled with management commands. This is the loop every translation cycle runs.

# Extract marked strings into locale/<lang>/LC_MESSAGES/django.po
python manage.py makemessages -l nl -l de -l fr
# ... translators fill in the msgstr entries ...
# Compile .po into the binary .mo Django actually loads
python manage.py compilemessages

The step people forget is compilemessages — edited translations do nothing until compiled, and a common "my translations aren't showing" bug is simply a missing compile in the deploy pipeline.

Guarding the catalog in CI

Two cheap checks catch most regressions: regenerate the catalog and fail if it differs from what is committed (someone added a string without extracting it), and compile it, which runs msgfmt --check-format and rejects translations whose placeholders do not match the source.

# ci/check_translations.sh
set -euo pipefail
python manage.py makemessages --all --no-obsolete --add-location file \
    --ignore ".venv/*" --ignore "node_modules/*"
python manage.py makemessages --all -d djangojs --no-obsolete --add-location file \
    --ignore ".venv/*" --ignore "node_modules/*" --ignore "static/dist/*"
git diff --exit-code -- locale/
python manage.py compilemessages --ignore ".venv/*"

The POT-Creation-Date header changes on every run, so either strip it before diffing or compare only msgid lines; otherwise the check fails constantly. Keep the compiled .mo files out of version control and produce them in the image build instead. That removes the "forgot to compile" failure mode entirely: if the build succeeds, the catalogs are fresh.

Pluralization

Languages pluralize differently — English has two forms, Polish has three, Japanese one — so never build "1 item / 2 items" by hand. Use ngettext (and {% blocktranslate count %} in templates), which lets each locale define its own plural rules.

from django.utils.translation import ngettext
msg = ngettext("%(n)d item", "%(n)d items", n) % {"n": n}

Language selection and URLs

Django picks the active language via LocaleMiddleware, which checks, in order, the URL prefix, the language cookie, the browser's Accept-Language header, and finally LANGUAGE_CODE (since Django 4.0 it no longer reads the session). For SEO and shareable links you usually want the language in the URL, which i18n_patterns provides.

urlpatterns += i18n_patterns(
    path("", include("shop.urls")),   # -> /nl/, /de/, /fr/ ...
)

URL-based selection gives each language a distinct, crawlable address and lets users bookmark and share pages in their language — far better than a language buried in a cookie.

Locale-aware formatting

Translation is only half of localization; formats matter just as much. With USE_I18N and format localization on, Django renders dates, times, and numbers in each locale's convention — 1.234,56 in German, 1,234.56 in English — through the {% localize %} tag and format-aware form fields. Hard-coding strftime("%m/%d/%Y") defeats all of this; let Django's format system render dates so each user sees their own convention.

Where localized formatting goes wrong

Since Django 5.0, localized formatting is always on (the old USE_L10N setting was removed), so every number rendered through a template variable is localized. That is exactly right for display text and exactly wrong for machine-readable output. A price rendered into <input type="number" value="{{ price }}"> becomes 1234,5 under German, which the browser rejects as invalid, and the field appears empty. The same happens with IDs in data- attributes and coordinates passed to a map library.

{% load l10n %}
<input type="number" step="0.01" value="{{ product.price|unlocalize }}">
<div data-product-id="{{ product.pk|unlocalize }}"
     data-lat="{{ store.lat|unlocalize }}"></div>

{# Human-facing text stays localized #}
<p>{{ product.price }} &middot; {{ order.created|date:"SHORT_DATE_FORMAT" }}</p>

Use named formats such as "SHORT_DATE_FORMAT" or "DATETIME_FORMAT" with the date filter rather than literal format strings, so each locale supplies its own pattern. If a locale's defaults do not suit you, override them with FORMAT_MODULE_PATH pointing at a package that contains, for example, formats/nl/formats.py defining SHORT_DATE_FORMAT. On the input side, form fields need localize=True to accept 1.234,56 from a German user; without it, a DecimalField parses only the dot-decimal form.

Time zones

Set USE_TZ = True and store all datetimes in UTC — this is non-negotiable for any app with users in more than one zone. Then activate the user's time zone per request so datetimes display in their local time, while the database stays in UTC. Getting this wrong produces the perennial bug of events showing hours off, or a "daily at midnight" job firing at the wrong local time. Store UTC, convert at the edge, never the reverse.

Activating the user's time zone

Django has no automatic time-zone detection; you store the zone name on the user (an IANA name such as Europe/Amsterdam, never a fixed offset like +01:00, which is wrong for half of the year) and activate it in middleware.

import zoneinfo
from django.utils import timezone

class UserTimezoneMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tzname = getattr(request.user, "timezone", "") or request.COOKIES.get("tz", "")
        try:
            timezone.activate(zoneinfo.ZoneInfo(tzname))
        except (zoneinfo.ZoneInfoNotFoundError, ValueError):
            timezone.deactivate()   # fall back to settings.TIME_ZONE
        return self.get_response(request)

Validate the name when users save it (the set returned by zoneinfo.available_timezones() is the reference), and have the front end send Intl.DateTimeFormat().resolvedOptions().timeZone as a sensible default for anonymous visitors.

"Today" is a per-user concept

The most common time-zone bug in reporting code is computing day boundaries in UTC. "Orders placed yesterday" for a user in Sydney starts at 13:00 or 14:00 UTC two calendar days earlier, depending on daylight saving. Use timezone.localdate() and pass tzinfo to date truncation so grouping happens in the user's zone, not the database's.

from datetime import datetime, time, timedelta
from django.db.models import Count
from django.db.models.functions import TruncDate
from django.utils import timezone

tz = timezone.get_current_timezone()
yesterday = timezone.localdate() - timedelta(days=1)
start = datetime.combine(yesterday, time.min, tzinfo=tz)
end = datetime.combine(yesterday + timedelta(days=1), time.min, tzinfo=tz)
orders = Order.objects.filter(created__gte=start, created__lt=end)

per_day = (Order.objects
           .annotate(day=TruncDate("created", tzinfo=tz))
           .values("day").annotate(n=Count("id")).order_by("day"))

Building both boundaries from local midnights means that on a DST transition day the resulting UTC span is 23 or 25 hours, which is the correct length for that local day. The comparison against stored UTC values works because Django converts aware datetimes to UTC when it builds the query.

Future events and recurring schedules

"Store UTC" has one important exception: future events defined in local time. A meeting at 09:00 in Berlin next March should still be at 09:00 local if the DST rules or the offset change between now and then. For recurring or far-future local events, store the local date and time plus the zone name, and compute the UTC instant when you need it (for example when scheduling the reminder). Past events, log timestamps, and anything that has already happened belong in UTC. Keep the tzdata package and the operating system's zone database updated in your image as well: time-zone rules change several times a year, and a stale database produces off-by-one-hour bugs for the affected regions.

Translating database content

Django's i18n translates your interface, not your data — a product name or article body stored in the database is not covered. For multilingual content you need a per-field translation strategy: libraries like django-parler or django-modeltranslation add translated columns or related rows so each record carries its text in every language. Decide this early, because retrofitting translated content onto a large schema is a painful migration, and it is the single most-overlooked part of "going multilingual".

Choosing a content-translation strategy

The two established libraries take opposite approaches, and there is a third, library-free option on PostgreSQL. The right choice depends mostly on how many languages you expect and how often you add them.

ApproachStorageAdding a languageQuerying and filteringBest fit
django-modeltranslationExtra column per field per language (name_en, name_nl)Schema migration on every translated tablePlain columns, easy to index and filter; no joinsA few stable languages, existing models you do not want to restructure
django-parlerSeparate translations table, one row per object per languageNo schema changeJoin to the translations table; needs care with prefetchingMany or growing set of languages, per-language completeness
JSONField per field{"en": "...", "nl": "..."} in one columnNo schema changeKey lookups (name__nl); expression indexes for hot paths; no per-language constraintsSimple catalogs, API-first apps, content managed by import jobs

With django-modeltranslation you register fields in a translation.py module, and the original attribute returns the value for the active language, falling back to the default language:

# shop/translation.py
from modeltranslation.translator import register, TranslationOptions
from .models import Product

@register(Product)
class ProductTranslationOptions(TranslationOptions):
    fields = ("name", "description")

# With nl active, product.name reads product.name_nl (or falls back to name_en)

With django-parler, the model itself declares its translated fields, and the queryset gains language-aware helpers:

from django.db import models
from parler.models import TranslatableModel, TranslatedFields

class Product(TranslatableModel):
    sku = models.CharField(max_length=40, unique=True)
    translations = TranslatedFields(
        name=models.CharField(max_length=200),
        description=models.TextField(blank=True),
    )

# Only products that actually have a Dutch name
Product.objects.translated("nl").prefetch_related("translations")

Whichever you pick, watch the N+1 pattern: a product list that touches a translated field per row will issue one query per product unless translations are prefetched. Search is the other hidden cost: full-text search needs a per-language configuration (PostgreSQL ships stemmers such as dutch and german), so index each language's text with its own SearchVector(..., config="dutch") rather than one English-stemmed vector for everything.

Right-to-left languages

Arabic, Hebrew, and Persian read right to left, which is a layout concern, not just a text one. Django exposes the current language's direction, and you flip your CSS to mirror the layout — menus, alignment, icons. Modern CSS logical properties (margin-inline-start instead of margin-left) make this far less painful than hand-mirroring every rule. If RTL locales are on your roadmap, build with logical properties from the start rather than retrofitting a mirror later.

Managing translations at scale

Emailing .po files to translators collapses past a couple of languages. At scale you connect a translation-management platform — Weblate, Transifex, Crowdin — that gives translators a proper editor, tracks what is new or changed, and syncs back to your files. This also handles the recurring reality that source strings keep changing: the platform flags exactly which translations went stale so nothing silently reverts to English on your next release.

Common pitfalls

Beyond lazy-in-migrations and missing compilemessages, the recurring mistakes are: concatenating translated fragments instead of using placeholders; forgetting that string context matters, so the same English word needing two translations requires pgettext to disambiguate; and treating translation as a one-time task rather than an ongoing process that every feature adds to. Marking strings is cheap; keeping translations current as the product evolves is the real work.

Translating JavaScript

Your interface strings are not only in Python and templates — increasingly they live in front-end code, and gettext stops at the server. Django's JavaScriptCatalog view ships your translations to the browser so client-side code can call a gettext equivalent with the same message files. Wire it up early if you have meaningful front-end interactivity, otherwise half your UI translates and the dynamic half silently stays in the source language, which users notice immediately.

Language fallbacks

Not every string is translated into every language at every moment, so you need a sensible fallback chain: an untranslated string should fall back to a related language or the default, never to a blank. Django falls back to the source language for missing UI strings automatically, but translated content needs its own policy — show the default-language version with a marker, or hide the item, but decide deliberately. A half-translated launch is normal; a page with blank gaps where translations are missing is a bug.

SEO for multilingual sites

Search engines need to know a page exists in several languages and which to show whom. Emit hreflang tags linking each language variant, generate a sitemap that includes every language's URLs, and keep language in the URL (via i18n_patterns) so each variant is independently crawlable and indexable. Getting this wrong means Google shows the wrong-language page in results or treats your translations as duplicate content — undoing much of the point of localizing.

Testing translations

Translation bugs hide until someone switches language, so test under a forced locale. Django's translation.override context manager and @override_settings(LANGUAGE_CODE=...) let you assert that a view renders the right language and that formats localize correctly.

from django.utils import translation

with translation.override("nl"):
    response = client.get("/nl/cart/")
    assert "Winkelwagen" in response.content.decode()

Testing at least one non-default locale end to end catches the whole class of "worked in English, broken in everything else" regressions.

Performance of translation

Loading and looking up translations has a cost, but Django caches compiled catalogs in memory, so the main performance rules are simple: compile your .mo files at build time (never translate from .po at runtime), and avoid doing translation work in tight loops where the lazy proxy is resolved repeatedly. For very high-traffic pages, cache the rendered, localized fragments per language. In practice translation is rarely a bottleneck if you compile ahead of time — the slowness people blame on i18n is almost always an uncompiled or misconfigured catalog.

Translating emails and notifications

Outbound messages are user-facing text that lives outside the request-response cycle, and they are routinely forgotten in localization. A confirmation email must render in the recipient's language, not the language of whatever process sent it — which means storing each user's preferred language and activating it explicitly when you build the message, since a Celery task has no request to infer it from. Render email subjects and bodies under translation.override(user.language) so the whole notification, including the subject line, arrives localized rather than half-translated.

Currency and money

Localization is not only language — money has its own rules. The symbol, its position, the decimal and thousands separators, and the number of decimal places all vary by locale, and the amount itself may differ by market. Never format money with naive string interpolation; use locale-aware formatting, and store amounts as integers of the smallest unit (cents) with an explicit currency code rather than floats. A library like py-moneyed/django-money models this properly. Money bugs are the ones users notice fastest and forgive slowest.

Sorting and collation by locale

Alphabetical order is language-specific: Swedish sorts å after z, German treats ä near a, and a naive byte sort gets both wrong. When you display sorted lists of names or terms, order them with the locale's collation rather than default database ordering, which Postgres supports through collation-aware indexes and ORDER BY ... COLLATE. For most apps a single sensible collation suffices, but if correct alphabetical order matters to your users — a directory, an index, a glossary — sorting by locale is the difference between "organized" and "subtly wrong in a way native speakers spot instantly".

Troubleshooting: why is this still in English?

Most i18n incidents come down to a handful of causes. Work through them in order before suspecting Django itself.

SymptomLikely causeFix
No strings translate at all in one language.mo missing in the deployed image, or LOCALE_PATHS points somewhere elseCompile in the build step; check the path inside the running container
One string stays in EnglishEntry is marked fuzzy, or the msgid changed (whitespace, punctuation)Review fuzzy entries; use {% blocktranslate trimmed %} so template reformatting does not change msgids
Model labels or choices always in the default languagegettext used at module level instead of gettext_lazySwitch to the lazy variant for anything evaluated at import
Language switch has no effectMiddleware order, or LANGUAGES does not include the codePlace LocaleMiddleware after sessions, before common
Edited translations appear only after a restartCatalogs are loaded once per processRestart workers on deploy; this is expected behavior
Wrong regional variant (pt instead of pt-br)Language code vs locale directory mismatchCodes use hyphens (pt-br); directories use locale names (pt_BR)
Cached fragment shown in the wrong languageCache key ignores languagecache_page and the per-site cache include the active language in their keys; custom and fragment caches must add it

To see what Django actually resolved, check translation.get_language() and request.LANGUAGE_CODE in a view, and confirm the catalog loaded by calling translation.gettext on a known string in python manage.py shell under translation.override. The {% cache %} template tag deserves a special mention: its key does not include the language, so pass LANGUAGE_CODE (from {% get_current_language as LANGUAGE_CODE %}) as one of its vary-on arguments, or the first visitor's language is served to everyone.

How much to invest

Do the i18n groundwork — marking strings, USE_TZ, format localization — early even if you launch in one language, because retrofitting it is far more expensive than doing it as you write. Add actual languages when there is a real audience for them, and translate content (not just UI) only for locales that justify the ongoing effort. The goal is a codebase that is ready to localize cheaply, so adding a language is a translation project, not a code rewrite.