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.
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.
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.
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.
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.
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.
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.
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.
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}
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.
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.
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 }} · {{ 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.
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.
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.
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.
"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.
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".
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.
| Approach | Storage | Adding a language | Querying and filtering | Best fit |
|---|---|---|---|---|
django-modeltranslation | Extra column per field per language (name_en, name_nl) | Schema migration on every translated table | Plain columns, easy to index and filter; no joins | A few stable languages, existing models you do not want to restructure |
django-parler | Separate translations table, one row per object per language | No schema change | Join to the translations table; needs care with prefetching | Many or growing set of languages, per-language completeness |
JSONField per field | {"en": "...", "nl": "..."} in one column | No schema change | Key lookups (name__nl); expression indexes for hot paths; no per-language constraints | Simple 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
Most i18n incidents come down to a handful of causes. Work through them in order before suspecting Django itself.
| Symptom | Likely cause | Fix |
|---|---|---|
| No strings translate at all in one language | .mo missing in the deployed image, or LOCALE_PATHS points somewhere else | Compile in the build step; check the path inside the running container |
| One string stays in English | Entry 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 language | gettext used at module level instead of gettext_lazy | Switch to the lazy variant for anything evaluated at import |
| Language switch has no effect | Middleware order, or LANGUAGES does not include the code | Place LocaleMiddleware after sessions, before common |
| Edited translations appear only after a restart | Catalogs are loaded once per process | Restart workers on deploy; this is expected behavior |
Wrong regional variant (pt instead of pt-br) | Language code vs locale directory mismatch | Codes use hyphens (pt-br); directories use locale names (pt_BR) |
| Cached fragment shown in the wrong language | Cache key ignores language | cache_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.
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.