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.

DjangoZen Team Jul 11, 2026 16 min read 5 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.

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 the URL prefix, session, and the browser's Accept-Language. 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.

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.

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".

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".

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.