Internationalization & Translation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and
PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing
was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, a suite of 34 tests
(manage.py test) and a curl session against runserver all passed, and the output shown is the real output. One limit
applies: the GNU gettext tools (xgettext, msguniq, msgfmt) are not installed on the machine that produced this page, so
makemessages and compilemessages could not be executed. Their real failure message is shown, the .po files in the scratch
project were written by hand in exactly the format makemessages produces, and a 60-line Python stand-in compiled them to .mo
for the tests. Nothing here needs PostgreSQL or Redis. The two third-party model-translation packages
(django-modeltranslation, django-parler) were not installed in the scratch project; their sections are written from the
packages' own documentation and say so.
A site that serves one language still benefits from marking its strings, because the same marks later drive translation, the admin, form errors and validation messages. This page covers the whole chain in the order a project meets it: the vocabulary and the settings, marking strings in Python and in templates, extracting and compiling message files, how Django discovers catalogues and picks a language per request, translated URLs, the language switcher, JavaScript, translating database content, and testing. Number, date and time formatting (the "l10n" half) and time zones are on Localization & Time Zones; this page only touches them where they change what a translated page prints. The settings machinery is on Settings & Configuration, the middleware chain on Middleware, the URL layer on URLs & Routing, and the template language on Templates.
In a hurry: what to use for what
| I need to… | Use | Section |
|---|---|---|
Translate a literal inside a view or a function |
|
|
Translate a literal at import time (model fields, form fields, settings, URL patterns) |
|
|
Pluralize correctly |
|
|
Disambiguate two meanings of one English word |
|
|
Translate template text |
|
|
Extract and compile translations |
|
|
Choose the language per request |
|
|
Put the language in the URL ( |
|
|
Let the visitor switch language |
A POST form to |
|
Translate strings in JavaScript |
|
|
Run code in another language without a request |
|
|
Translate database content (a book’s title, a genre name) |
Not built in. A hand-written field per language, |
|
Turn translation off for speed |
|
Vocabulary and the end-to-end flow
Django’s documentation separates two words that people use interchangeably. Internationalization (i18n) is preparing the software for translation, usually done by developers. Localization (l10n) is writing the translations and local formats, usually done by translators. A few more terms recur through the page:
| Term | Meaning |
|---|---|
Locale name |
A language, or a language plus country, written |
Language code |
The same idea written with a dash, as browsers send it in the |
Message file |
A plain-text |
Compiled catalogue |
The binary |
Domain |
|
Translation string |
Any literal marked so that tools can find it and Django can translate it. |
A translatable project moves through five steps. Developers mark strings in code and templates, makemessages extracts them into
a .po file per language, translators fill in the msgstr values, compilemessages compiles each .po into a .mo, and at
run time Django activates one language per request and looks every marked string up in the merged catalogue.
Two warnings from the official overview are worth repeating. The setting that turns all of this on or off is called USE_I18N,
although it controls translation, not formatting (formatting is always localized since Django 5.0 removed USE_L10N). And Django
assumes that the original strings in the source are written in English; a non-English base language works but has limits, covered
in A non-English base language.
The Bookshelf pieces used on this page
Every example belongs to the app catalog of the Bookshelf project that grows across this section. The scratch project here
offers three languages: English (the source language), Dutch (nl, two plural forms) and Polish (pl, three plural forms, used
to show why ngettext exists). A Genre model carries a hand-written Dutch name to illustrate content translation later.
The settings that matter
Everything starts in settings.py. The listing shows only the i18n-related parts of a startproject file, plus the
SECRET_KEY read from the environment as in the earlier pages.
import os
from pathlib import Path
from django.core.management.utils import get_random_secret_key
from django.utils.translation import gettext_lazy as _ # lazy: settings load before translation is ready
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") or get_random_secret_key()
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"catalog",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", # after Session, before Common
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.i18n", # LANGUAGES, LANGUAGE_CODE, LANGUAGE_BIDI
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
USE_I18N = True
LANGUAGE_CODE = "en" # the source language and the last-resort fallback
LANGUAGES = [ # the languages a visitor may select
("en", _("English")),
("nl", _("Dutch")),
("pl", _("Polish")),
]
LOCALE_PATHS = [BASE_DIR / "locale"] # project-level catalogues, highest precedence
LANGUAGE_COOKIE_SAMESITE = "Lax"
manage.py check reports no issues for this configuration. The table is the complete list of i18n settings and what each one does.
| Setting | Default | Purpose |
|---|---|---|
|
|
Enables the translation machinery. |
|
|
The installation-wide language. Serves every user when the locale middleware is absent; with the middleware it is the fallback
for an undetermined language and for strings missing from the user’s catalogue; it also supplies the formats used when
localization is switched off in a template ( |
|
every language Django ships |
The languages that may be selected. Restrict it to the languages you actually translate. Mark the names with |
|
the right-to-left languages Django knows |
Codes written right to left; drives |
|
|
Extra directories searched for |
|
|
A package with per-locale |
|
|
Name of the cookie that remembers the visitor’s choice. |
|
|
Cookie lifetime in seconds; |
|
|
Set to e.g. |
|
|
Path of the cookie; useful when several Django projects share a host name. |
|
|
Hide the cookie from JavaScript. Leave |
|
|
The |
|
|
Mark the cookie |
The three cookie attribute settings (LANGUAGE_COOKIE_HTTPONLY, LANGUAGE_COOKIE_SAMESITE, LANGUAGE_COOKIE_SECURE) were added
in Django 3.0, and their defaults preserve the earlier behaviour. The official reference warns that changing
LANGUAGE_COOKIE_DOMAIN or LANGUAGE_COOKIE_PATH on a live site strands visitors whose browsers still hold the old cookie: the
only reliable migration is a new LANGUAGE_COOKIE_NAME plus a middleware that copies the old value to the new cookie and deletes
the old one.
Marking strings in Python
Import gettext and, by convention, alias it to _:
from django.http import HttpResponse
from django.utils.translation import gettext as _
def greeting(request, name):
# Translators: %(name)s is the reader's first name.
return HttpResponse(_("Hello, %(name)s!") % {"name": name})
Run against the scratch project, curl shows the English literal and the Dutch translation (the Dutch page lives at a Dutch URL,
see Translated URLs: i18n_patterns):
$ curl -s http://127.0.0.1:51731/en/hello/Eva/
Hello, Eva!
$ curl -s http://127.0.0.1:51731/nl/hallo/Eva/
Hallo, Eva!
Rules that follow from how extraction and lookup work:
-
Only functions with a single string argument may be aliased to
_:gettextandgettext_lazy. The others (ngettext,pgettext, …) must be imported by name becausexgettext(the extractor) is configured with their argument layout. -
Django deliberately does not install
()into the builtins as the standard library’sgettext.install()would. The underscore means "the previous result" in the interactive shell and in doctests, and a globalwould also hide the question of whether this file wants the eager or the lazy variant. -
Computed values and variables translate but cannot be extracted.
(" ".join(words))or(sentence)work at run time, yetmakemessagesnever sees the literal, so it never reaches a.pofile. Keep the literal visible. -
Use named placeholders, not positional ones, once there is more than one.
_("Today is %(month)s %(day)s.") % {…}lets a translator reorder the words (Spanish: "Hoy es 26 de noviembre.");%s %sdoes not. -
Never use an f-string.
_(f"Welcome {name}")interpolates beforegettextis called, so the lookup key is the finished sentence and no translation can ever match. Use_("Welcome %(name)s") % {"name": name}or_("Welcome {name}").format(name=name). -
Literal percent signs. A string such as
"10% interest"is mis-detected as a Python format string and the compile step then fails with "number of format specifications … does not match". Write"10%% interest"or add the comment# xgettext:no-python-formatabove the call. -
Comments for translators. A comment that begins with the keyword
Translatorson the line above the call is copied into the.pofile as#. Translators: …. The same works in templates (see Marking strings in templates).
Plurals: ngettext
English has two plural forms; Polish has three, Arabic six, Japanese one. Django therefore never asks you to decide which form to
use: you give the singular, the plural and the number, and the catalogue’s Plural-Forms rule picks.
from django.utils.translation import ngettext
from .models import Book
def shelf_summary():
count = Book.objects.count()
return ngettext(
"There is %(count)d book on the shelf.",
"There are %(count)d books on the shelf.",
count,
) % {"count": count}
The Polish catalogue in the scratch project declares nplurals=3 with the standard Polish rule, and the test below passes
1, 2, 5 and 22 to the same call:
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "There is %(count)d book on the shelf."
msgid_plural "There are %(count)d books on the shelf."
msgstr[0] "Na półce jest %(count)d książka."
msgstr[1] "Na półce są %(count)d książki."
msgstr[2] "Na półce jest %(count)d książek."
def test_plural_forms(self):
def msg(n):
return ngettext(
"There is %(count)d book on the shelf.",
"There are %(count)d books on the shelf.",
n,
) % {"count": n}
with translation.override("pl"):
assert [msg(n) for n in (1, 2, 5, 22)] == [
"Na półce jest 1 książka.",
"Na półce są 2 książki.",
"Na półce jest 5 książek.",
"Na półce są 22 książki.",
]
Three pitfalls the official page spells out:
-
Do not roll your own singular-or-plural logic. Choosing
verbose_nameorverbose_name_pluralwithif count == 1is wrong for every language whose rule is not "one versus many". Put the model’sverbose_nameinside both sentences instead. -
Use the same placeholder names in the singular and the plural literal. A singular using
%(name)sand a plural using%(plural_name)smakescompilemessagesfail with "a format specification for argument 'name', as in 'msgstr[0]', doesn’t exist in 'msgid'". -
With the lazy variants the count is not known at definition time.
ngettext_lazyaccepts a key name as its third argument and looks the number up in the dictionary used for the later%interpolation:
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import ngettext_lazy
class CartForm(forms.Form):
too_many = ngettext_lazy(
"You may only borrow %(num)d book at a time.",
"You may only borrow %(num)d books at a time.",
"num",
)
def clean(self):
if self.cleaned_data.get("books", 0) > 3:
raise ValidationError(self.too_many % {"num": 3})
If a lazy plural string contains exactly one unnamed placeholder you can interpolate the number directly:
message % number.
Contextual markers: pgettext and npgettext
The English word May is a month and a verb; many languages use two different words. pgettext(context, message) (and
npgettext(context, singular, plural, number) when it also pluralizes) adds a context string that becomes a msgctxt line, so the
same msgid can have one translation per context.
from django.utils.translation import pgettext
month = pgettext("month name", "May")
verb = pgettext("verb", "May")
msgctxt "month name"
msgid "May"
msgstr "mei"
msgctxt "verb"
msgid "May"
msgstr "mogen"
With the Dutch catalogue active, the home page prints mei / mogen / mei for pgettext("month name", …),
pgettext("verb", …) and the template tag {% translate "May" context "month name" %} respectively.
Marking without translating: gettext_noop
gettext_noop marks a string for extraction but returns it unchanged. Use it for constants that must stay in the source language
because they travel through other systems (a status stored in a database, a value in an API) and that you translate at the last
moment, when they are shown.
from django.utils.translation import gettext as _
from django.utils.translation import gettext_noop
STATUS_LABELS = {
"draft": gettext_noop("Draft"),
"live": gettext_noop("Published"),
}
shown = [_(label) for label in STATUS_LABELS.values()] # translate here, not at import
Under the Dutch catalogue STATUS_LABELS["live"] is still "Published" while shown becomes Concept, Online gezet (the
project-level catalogue overrides the app’s, see How Django discovers catalogues).
Lazy translation: translating late
gettext("x") translates now, in whichever language is active now. Code that runs when a module is imported runs before any
request exists, so it would freeze the translation of whatever language happened to be active, or none. The *_lazy functions
return a proxy object that stores the literal and translates when it is finally used as a string, typically while a template
renders.
Use the lazy variants wherever the value is evaluated at import time:
-
model field
verbose_nameandhelp_text, relationverbose_name, andMeta.verbose_name/verbose_name_plural(always give explicit lowercase names rather than relying on Django’s English-only guess from the class name) -
form field labels, help texts and error messages
-
LANGUAGESnames and URL patterns -
the
descriptionargument of@admin.displayand@admin.action -
module-level constants
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
class Genre(models.Model):
code = models.SlugField(unique=True)
name_en = models.CharField(_("name (English)"), max_length=100)
name_nl = models.CharField(_("name (Dutch)"), max_length=100, blank=True)
class Meta:
verbose_name = _("genre")
verbose_name_plural = _("genres")
def __str__(self):
return self.name_en
class Book(models.Model):
title = models.CharField(_("title"), max_length=200)
genre = models.ForeignKey(
Genre,
on_delete=models.PROTECT,
related_name="books",
verbose_name=_("genre"),
)
price = models.DecimalField(
_("price"),
max_digits=6,
decimal_places=2,
help_text=pgettext_lazy("help text for Book.price", "Price in euros."),
)
class Meta:
verbose_name = _("book")
verbose_name_plural = _("books")
ordering = ["title"]
def __str__(self):
return self.title
The behaviour the test suite checks:
with translation.override("nl"):
assert str(Book._meta.verbose_name_plural) == "boeken"
assert str(Book._meta.get_field("price").help_text) == "Prijs in euro."
Things to know when you hold a lazy object:
-
It behaves like a
strin Django code, but arbitrary Python (therequestslibrary,json.dumps, a database driver) may not accept it. Convert withstr(value)at the boundary, and only there, becausestr()freezes the current language. -
str.format()does not work when the format string or an argument is lazy. Usedjango.utils.text.format_lazy, which creates another lazy object that formats only when finally rendered:
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy
TAGLINE = format_lazy(
"{name}: {slogan}",
name=gettext_lazy("Bookshelf"),
slogan=gettext_lazy("read more"),
)
With Dutch active, str(TAGLINE) is Boekenplank: lees meer; with English active it is Bookshelf: read more.
-
To delay your own function, wrap it with
django.utils.functional.lazy(function, str); thento_lower_lazy(_("My STRING!"))stays lazy. -
Lazy and non-lazy are not interchangeable. Calling the eager
gettextin a model field would translate once, at import, into whatever language is active then. Calling a lazy one in a view is harmless but pointless.
Information about languages
get_language_info(code) (and the template tags and filters in Marking strings in templates) returns a dictionary with the language’s code,
its English name, its name in itself, its name in the currently active language, and a right-to-left flag:
>>> from django.utils.translation import activate, get_language_info
>>> activate("fr")
>>> li = get_language_info("de")
>>> print(li["name"], li["name_local"], li["name_translated"], li["bidi"])
German Deutsch Allemand False
The name_translated key is the one to use in a language menu shown in the visitor’s own language. The same shell session,
run in the scratch project, returned exactly German Deutsch Allemand False.
Marking strings in templates
Template translation has its own two tags and its own syntax. Every template that uses them must contain {% load i18n %}
near the top, including templates that extend a base template that already loaded it, because load is per template.
{% translate %}
{% translate %} translates a constant string (single or double quotes) or the value of a variable. Internally it is a
gettext call. When you pass a variable, the tag first resolves it and then looks the resulting string up in the catalogue; it
cannot mix variables inside a literal (use blocktranslate for that).
| Form | Effect |
|---|---|
|
Translate the constant. |
|
Resolve |
|
Do the lookup of variables but skip the translation. Stub for text that will be translated later; the literal is still extracted. |
|
Store the translation in |
|
Contextual marker ( |
{% load i18n %}
{% translate "Back to the shelf" as back_label %}
<a id="back" href="{% url 'catalog:home' %}" title="{{ back_label }}">{{ back_label }}</a>
<p id="contexts">{{ month }} / {{ verb }} / {% translate "May" context "month name" %}</p>
{% comment %}Translators: label of a button that triggers search{% endcomment %}
<button>{% translate "Search" %}</button>
Rendered with the Dutch catalogue the three lines become Terug naar de plank (twice: link text and title), mei / mogen / mei
and Zoeken. The comment block above the button is the template equivalent of a # Translators: comment: it is copied to the
.po file as #. Translators: label of a button that triggers search. The one-line form {# Translators: … #} works too.
{% blocktranslate %}
{% blocktranslate %} marks a whole sentence that contains variables. Variables appear as {{ name }} placeholders and become
%(name)s in the msgid. To translate an expression (an attribute lookup, a filter) bind it to a local name with with; other
block tags such as for or if are not allowed inside the block. A failed lookup of a block argument temporarily deactivates the
language (deactivate_all()), so the message falls back to the source text.
| Option | Effect |
|---|---|
|
Bind expressions to names used inside the block. (The older |
|
Bind the number that selects the plural form; add |
|
Strip the newlines and indentation, joining lines with one space, so that indenting the block does not pollute the |
|
Contextual marker. In the |
|
Store the result in |
{% blocktranslate trimmed count counter=books|length %}
One book is listed below.
{% plural %}
{{ counter }} books are listed below.
{% endblocktranslate %}
<ul>
{% for book in books %}
<li>
{% blocktranslate trimmed with title=book.title|title price=book.price genre=book.genre.name_en context "book line" %}
{{ title }} ({{ genre }}) costs {{ price }} euros
{% endblocktranslate %}
</li>
{% endfor %}
</ul>
The corresponding .po entries are the ones makemessages writes: trimmed keeps msgid on one line, the counter becomes
%(counter)s, and the context becomes msgctxt.
msgid "One book is listed below."
msgid_plural "%(counter)s books are listed below."
msgstr[0] "Hieronder staat een boek."
msgstr[1] "Hieronder staan %(counter)s boeken."
msgctxt "book line"
msgid "%(title)s (%(genre)s) costs %(price)s euros"
msgstr "%(title)s (%(genre)s) kost %(price)s euro"
The Dutch page printed by runserver (the curl output of /nl/):
Hieronder staan 2 boeken.
<ul>
<li>
Dune (Science fiction) kost 9,50 euro
</li>
<li>
Emma (Science fiction) kost 7,00 euro
</li>
</ul>
Note the decimal comma: {{ price }} is a variable, and variables are formatted for the active language. That is
localization, not translation, and it is why the same template prints 9.50 under en and 9,50 under nl. See
Localization & Time Zones.
Rules specific to blocktranslate:
-
The same naming rule as
ngettextapplies to a plural block with extrawithvariables: it compiles to anngettextcall, so use the same variable names in both branches. -
A reverse URL cannot be computed inside the block. Compute it first:
{% url 'path.to.view' arg as the_url %}and then use{{ the_url }}inside. -
asvaris the way to build a translated string for use as a tag argument or an attribute:
{% blocktranslate asvar the_title %}The title is {{ title }}.{% endblocktranslate %}
<title>{{ the_title }}</title>
<meta name="description" content="{{ the_title }}">
-
Translations are not auto-escaped; variables inside them are. The text of a
msgstris trusted markup, so translators can include<em>, and a stray double quote renders unchanged. Variables used inside the block are still escaped: rendering the sentenceHello, {{ name }}!withnameequal to<b>producedHello, <b>!in the test. Treat the.pofiles as code that needs review, not as user input.
trans and blocktrans are still accepted
Before Django 3.1 the tags were called trans and blocktrans. Django 3.1 renamed them to translate and blocktranslate
and kept the old names as aliases that, per the release notes, "will be retained for the foreseeable future". The test suite renders
{% trans "Search" %} under Dutch and gets Zoeken. Existing templates need no change; write new ones with the new names.
String literals inside other tags and filters
A string literal written as _("…") in an argument of a tag or filter is extracted and translated before the tag sees it:
{{ flag|yesno:_("yes,no") }}
The unit translated is the whole string "yes,no", not "yes" and "no" separately, so a German translator must keep the comma:
ja,nein.
Switching the language inside a template: {% language %}
The language block activates a language for the enclosed section only. The home page uses it to show one sentence in English
while the page is Dutch, and to build a link to the same view in every language:
{% language "en" %}<p id="forced-en">{% translate "Welcome to the Bookshelf." %}</p>{% endlanguage %}
{% get_available_languages as langs %}
{% for code, name in langs %}
{% language code %}<a href="{% url 'catalog:about' %}">{{ code }}</a>{% endlanguage %}
{% endfor %}
Output of the page under /nl/ (note that the link for each language is built with that language’s own URL pattern, including the
translated path segment):
<p id="forced-en">Welcome to the Bookshelf.</p>
<p id="links">
<a href="/en/about/">en</a>
<a href="/nl/over/">nl</a>
<a href="/pl/about/">pl</a>
</p>
The Polish link is /pl/about/ because no Polish translation of the URL segment exists, so the source text is used.
The remaining i18n tags, filters and the context processor
| Tag or filter | Result |
|---|---|
|
List of |
|
The active code as a string, for example |
|
|
|
A dictionary-like object: |
|
The same information for a list of |
|
Filters for one code: |
|
With |
The base template of the scratch project uses get_current_language, get_current_language_bidi and
get_language_info_list to set lang and dir on <html> and to build the switcher. Under /nl/ it produced
<html lang="nl" dir="ltr"> and the line nl Nederlands Dutch Nederlands False from get_language_info. The filter test
{{ "de"|language_name }}|{{ "de"|language_name_local }}|{{ "ar"|language_bidi }} returned German|Deutsch|True.
Message files: makemessages and compilemessages
The GNU gettext prerequisite
makemessages shells out to xgettext, msguniq and msgmerge, and compilemessages to msgfmt. The official minimum is
gettext 0.19 (raised from 0.15 in Django 5.2). Install the package called gettext with your system’s package manager; on Windows
download a precompiled gettext build and make sure xgettext --version works at a command prompt. JavaScript template strings
additionally need gettext 0.21 or newer.
On the machine used to verify this page no gettext tools are installed. The official page still says that without gettext
makemessages "will create empty files"; with Django 6.1.1 the commands instead stop with an explicit error:
$ python manage.py makemessages -l nl
CommandError: Can't find msguniq. Make sure you have GNU gettext tools 0.19 or newer installed.
$ python manage.py compilemessages
CommandError: Can't find msgfmt. Make sure you have GNU gettext tools 0.19 or newer installed.
Because of this, the .po files in the scratch project were written by hand following the format below, and compiled with a small
Python stand-in so the tests could exercise real .mo files. In a real project you run the two commands.
Extracting strings: makemessages
Run it from the project root (the directory containing manage.py) or from an app’s root:
$ django-admin makemessages -l nl # create or update locale/nl/LC_MESSAGES/django.po
$ django-admin makemessages -a # update every existing language
$ django-admin makemessages -d djangojs -l nl # JavaScript strings -> djangojs.po
Where the strings land: run from the project root, a string from an app that has its own locale/ directory goes into that app’s
message file; a string from an app without one goes into the first directory in LOCALE_PATHS, or the command fails if
LOCALE_PATHS is empty. That is the reason the scratch project defines LOCALE_PATHS. Locale names are written with an
underscore (pt_BR, de_AT, sr_Latn); since Django 4.2 locales with private sub-tags work, and since 3.2 a locale containing a
hyphen is no longer silently processed.
| Option | Meaning |
|---|---|
|
Locale(s) to process; repeat the option for several. |
|
Locale(s) to skip. |
|
Update the message files of all existing languages. |
|
File extensions to scan. Default |
|
|
|
Follow symbolic links to directories. |
|
Glob pattern to skip, repeatable. Defaults ignored: |
|
Switch the default ignore patterns off. |
|
Do not break long message lines. |
|
Omit the |
|
|
|
Remove obsolete entries (strings no longer in the source) from the |
|
Keep the temporary |
The command does not need configured settings, but without settings it cannot skip MEDIA_ROOT and STATIC_ROOT or include
LOCALE_PATHS. A practical command for a project with vendored front-end code is
django-admin makemessages -l nl -i node_modules -i "static/vendor/*".
Two behavioural changes are worth knowing for version control: since Django 4.1 makemessages no longer rewrites a .po file that
is already up to date (before, POT-Creation-Date changed on every run and produced noisy diffs), and msgmerge marks entries
whose msgid changed slightly as fuzzy.
The anatomy of a .po file
The scratch project’s Dutch file shows the whole format. After makemessages -l nl the entries have empty msgstr values and a
#: location comment; the translator fills them in.
msgid ""
msgstr ""
"Project-Id-Version: bookshelf\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Welcome to the Bookshelf."
msgstr "Welkom bij de Boekenplank."
msgid "There is %(count)d book on the shelf."
msgid_plural "There are %(count)d books on the shelf."
msgstr[0] "Er staat %(count)d boek op de plank."
msgstr[1] "Er staan %(count)d boeken op de plank."
msgid "about/"
msgstr "over/"
msgid "hello/<str:name>/"
msgstr "hallo/<str:name>/"
-
msgidis the source text and must not be edited;msgstris the translation. A long string is written as an empty first line followed by quoted pieces that are concatenated, so mind the trailing spaces inside each piece. -
The header’s
Plural-Formsline defines how manymsgstr[n]there are and how a number picks one. Translation editors fill it in; Django only reads it. -
The file must be UTF-8 without a byte-order mark. A text editor that adds a BOM breaks loading.
-
fuzzyentries (a guessmsgmergemade from a similar string) are skipped bycompilemessagesunless you pass--use-fuzzy. Review and remove the flag rather than shipping fuzzy text. -
#. Translators: …lines are the comments you wrote for translators;#: path:linelines are generated locations. -
URL patterns are translated like any other string: the msgid is the whole pattern text,
hello/<str:name>/, converter included. Translate the words and keep<str:name>exactly.
Compiling: compilemessages
django-admin compilemessages turns every .po it finds into a .mo next to it. Run it from the same directory as
makemessages, or point it at the settings file (--settings=path.to.settings) so it also processes every directory in
LOCALE_PATHS.
$ django-admin compilemessages # all locales
$ django-admin compilemessages -l nl -l pl # only these
$ django-admin compilemessages -x pl # all except pl
$ django-admin compilemessages --use-fuzzy # include fuzzy entries
$ django-admin compilemessages --ignore=node_modules --ignore="outdated/*/locale"
| Option | Meaning |
|---|---|
|
Locale(s) to compile; default all. |
|
Locale(s) to exclude. |
|
Include fuzzy entries in the compiled file. |
|
Skip directories matching the glob; repeatable. Added in Django 3.0. |
Treat .mo files as build artifacts: generate them in CI or in the image build (RUN python manage.py compilemessages, covered on
the production deployment page of this section), keep the .po files in version control,
and do not forget that a deployment without .mo files silently shows English.
Customizing makemessages
To teach the extractor an extra keyword (for example a project helper called mytrans) subclass the command and extend
xgettext_options. The file must live in an app’s management/commands/ directory under the same name, so it overrides the built-in one:
from django.core.management.commands import makemessages
class Command(makemessages.Command):
# Also extract strings marked with a project-specific helper called mytrans().
xgettext_options = makemessages.Command.xgettext_options + ["--keyword=mytrans"]
Loaded through Django’s command machinery in the scratch project, the class reports
['--from-code=UTF-8', '--add-comments=Translators', '--keyword=mytrans'], while the built-in class lists only the first two.
For per-run keywords, add an argument in add_arguments and extend xgettext_options inside handle, as the official page shows.
Jinja2 templates and Babel
makemessages understands only the Django template language. A project that renders Jinja2 templates must extract their strings
with Babel instead, using a babel.cfg that lists the Python and Jinja2 extractors; Babel produces the same .po files and does
not depend on gettext at all, which also makes it a way to extract on a machine without gettext.
How Django discovers catalogues
At first use Django builds one in-memory catalogue per language by merging every .mo it finds, in this order of precedence:
-
The directories in
LOCALE_PATHS, first entry first. -
The
locale/directory of each app inINSTALLED_APPS, first app first. -
Django’s own
django/conf/locale/(107 locale directories in 6.1.1), the fallback.
All three use the same layout: <language>/LC_MESSAGES/django.(po|mo), with the directory named by locale name (pt_BR).
Untranslated strings of a territorial variant (pt_BR) fall back to the generic language (pt).
This is how a project overrides a wording Django or a third-party app ships: put the same msgid in a project-level
locale/ listed in LOCALE_PATHS. The scratch project does it on purpose. The app catalogue says Published is Gepubliceerd;
the project catalogue says Online gezet, and the project wins:
msgid "Published"
msgstr "Online gezet"
curl http://127.0.0.1:51731/nl/language/ returned "label": "Online gezet".
Catalogues are built lazily and cached per process. In development runserver watches the .mo files in LOCALE_PATHS and in each
non-Django app’s locale/ directory and clears the translation caches when one changes (the reloader module is
django.utils.translation.reloader in the source), so a recompile shows up on the next request. In production each worker process keeps
the catalogue it loaded, so restart or reload the workers after deploying new .mo files. Since Django 3.1 the reloader no longer
reacts to changes inside Django’s own built-in translation files.
A non-English base language
Django assumes English source strings. If you write the source strings in another language you can, but: gettext offers only two
plural forms for the original messages, so you must also provide a translation for the base language when its plural rule differs
from English; and a visitor using an English variant, when an English translation is missing, gets the original strings, not the
project’s LANGUAGE_CODE. For example, an English user visiting a site whose LANGUAGE_CODE is Spanish and whose source strings are
Russian sees Russian. Most teams keep English sources and add en only if they need to polish wording.
Choosing the language per request: LocaleMiddleware
With no middleware, LANGUAGE_CODE decides the language for every visitor: "static" translation. To let each visitor have their
own, add django.middleware.locale.LocaleMiddleware. It reads the request, picks a language, activates it for the current
thread or task, and exposes it as request.LANGUAGE_CODE.
Where it sits in the request cycle
Middleware order matters, and the official guidance has three rules:
-
Put it in the first few entries.
-
It must come after
SessionMiddleware(the locale logic may read session-backed data) and beforeCommonMiddleware(becauseCommonMiddlewareneeds an activated language to resolve the requested URL, and withi18n_patternsthe URL depends on the active language). -
If you use the cache middleware, put
LocaleMiddlewareafter it.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
The detection algorithm
For each request the middleware tries these sources in order and stops at the first that yields a usable language:
-
The language prefix in the URL path, but only if the root URLconf uses
i18n_patterns(see Translated URLs:i18n_patterns). -
The language cookie, whose name is
LANGUAGE_COOKIE_NAME(defaultdjango_language). -
The
Accept-Languageheader that the browser sends. Django tries each language in the header, in priority order, until one has available translations. -
LANGUAGE_CODE, the global fallback.
Rules that apply at every step:
-
Preferences are expected as language codes with a dash (
pt-br). -
If the base language exists but the sublanguage does not, the base language is used:
de-atselectsde. -
Only languages listed in
LANGUAGEScan be selected, whether the preference comes from the URL, the cookie or the header. RestrictLANGUAGESto the languages you really ship; otherwise a visitor whose header saysfris served in French and every string you never translated stays English in the middle of Django’s own French messages. -
A language in
LANGUAGESmust also have a catalogue Django can load (check_for_language); a code that is listed but has no.moanywhere is skipped.
The scratch project’s tests walk through the order. The URL prefix beats the cookie and the header; the cookie beats the header
(a cookie for a language outside LANGUAGES is ignored); an unprefixed URL with only a header is redirected:
$ curl -si -H 'Accept-Language: nl-BE,nl;q=0.8,en;q=0.5' http://127.0.0.1:51731/language/
HTTP/1.1 302 Found
Location: /nl/language/
Vary: Accept-Language, Cookie
$ curl -si http://127.0.0.1:51731/nl/language/ | grep -i content-language
Content-Language: nl
$ curl -s http://127.0.0.1:51731/nl/language/
{"request_language": "nl", "active": "nl", "bidi": false, "label": "Online gezet"}
$ curl -s -H 'Cookie: django_language=pl' http://127.0.0.1:51731/en/language/
{"request_language": "en", "active": "en", "bidi": false, "label": "Published"}
In the second and third responses the explicit /nl/ or /en/ prefix won over the cookie. nl-BE from the header was reduced to
nl because LANGUAGES lists only nl.
What the middleware does to the response
On the way out LocaleMiddleware does three things that matter for caches and clients:
-
Sets
Content-Languageto the active language if the view did not set one. -
Adds
Accept-Languageto theVaryheader, unless the URL carried a language prefix (the URL already distinguishes the variants). In thecurloutput above, the prefixed/nl/language/response has noVary: Accept-Language, while an unprefixed response does. -
For a 404 on an unprefixed path, when
i18n_patternsis in use withprefix_default_language=True, it tries the same path with the language prefix and, if that path resolves (also trying to append a slash whenAPPEND_SLASHis on), redirects there. The redirect carriesVary: Accept-Language, Cookieso shared caches do not serve one visitor’s redirect to another. The redirect class is the attributeresponse_redirect_class(defaultHttpResponseRedirect); subclass the middleware to issue permanent redirects instead.
Inside a view, the active language is request.LANGUAGE_CODE (dynamic translation) or settings.LANGUAGE_CODE (static). Prefer
django.utils.translation.get_language(), which also works in code that does not receive the request.
from django.http import JsonResponse
from django.utils import translation
from django.utils.translation import gettext_lazy
def language_info(request):
return JsonResponse(
{
"request_language": request.LANGUAGE_CODE,
"active": translation.get_language(),
"bidi": translation.get_language_bidi(),
"label": str(gettext_lazy("Published")),
}
)
Source: Django: django.utils.translation
Pitfalls
-
Position is not optional. Placing
LocaleMiddlewareafterCommonMiddlewaremakes URL resolution happen before a language is active;i18n_patternsURLs andAPPEND_SLASHredirects then use the wrong language. -
The 404 redirect only works when the same path exists under the prefix. With translated URL segments, the unprefixed path
/about/is not redirected when the Dutch route is/nl/over/: the scratch project returned404for/about/with the Dutch cookie set, because/nl/about/is not a valid path. See Translated URLs:i18n_patterns. -
Remember the middleware is the only thing that activates a language. A view that is reached without it (a management command, a task, a signal handler) runs in whichever language is active on that thread, which after a test client request may be left over from the previous request. See Testing translated code.
-
Caches must vary on the language. A response that depends on the language and is cached without that key leaks one language to all. The middleware adds
Vary: Accept-Languagefor unprefixed URLs;cache_pageand the per-site cache middleware honourVary, and a CDN must be configured to as well. Prefixed URLs make caching simpler: each language has its own URL. -
set_languageandLANGUAGESare checked separately. The view accepts any code for which a catalogue exists (check_for_language), but the middleware later ignores a cookie whose code is not inLANGUAGES, so a hand-crafted POST for a language you do not offer sets a cookie that does nothing.
Translated URLs: i18n_patterns
Two independent mechanisms exist. i18n_patterns adds the active language as a prefix to every route inside it, so
LocaleMiddleware can read the language from the URL. gettext_lazy around a pattern string translates the path segments
themselves. Both require LocaleMiddleware, because both need an active language per request.
The language prefix
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import include, path
from django.views.i18n import JavaScriptCatalog, JSONCatalog
from catalog import views as catalog_views
urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), # set_language, language independent
path("language/remember/", catalog_views.remember_language),
path("async/", catalog_views.async_greeting),
]
urlpatterns += i18n_patterns(
path("admin/", admin.site.urls),
path("jsi18n/", JavaScriptCatalog.as_view(packages=["catalog"]), name="javascript-catalog"),
path("jsoni18n/", JSONCatalog.as_view(packages=["catalog"]), name="json-catalog"),
path("", include("catalog.urls")),
)
i18n_patterns(*urls, prefix_default_language=True) returns a single resolver that prepends the current language code to its
routes. Routes outside the call keep their plain paths: the language switcher endpoint, health checks, robots.txt, webhooks and
API endpoints usually belong outside it.
| Rule | Why |
|---|---|
Only in the root URLconf |
Using it in an included URLconf raises |
|
Drops the prefix for the default language ( |
Do not include |
The endpoint must be language independent. The scratch project keeps |
Wrap |
If the root URLconf uses |
Avoid unprefixed routes that collide with a prefix |
A route |
Languages with a script and a region |
Codes that combine a script and a region are supported since Django 4.1. |
A test that asserts the reverse results, in the active language:
with translation.override("en"):
assert reverse("catalog:about") == "/en/about/"
assert reverse("catalog:greeting", kwargs={"name": "Ana"}) == "/en/hello/Ana/"
with translation.override("nl"):
assert reverse("catalog:about") == "/nl/over/"
assert resolve("/nl/over/").url_name == "about"
assert self.client.get("/nl/hallo/Ana/").content.decode() == "Hallo, Ana!"
assert self.client.get("/nl/about/").status_code == 404
Translating the path segments
from django.urls import path
from django.utils.translation import gettext_lazy as _
from . import views
app_name = "catalog"
urlpatterns = [
path("", views.home, name="home"),
path(_("about/"), views.about, name="about"),
path(_("hello/<str:name>/"), views.greeting, name="greeting"),
path("language/", views.language_info, name="language"),
]
With the Dutch catalogue, reverse("catalog:about") returns /nl/over/ and /nl/about/ is a 404, while the same name under
English returns /en/about/. Remember that:
-
The
msgidis the whole pattern string, including converters:hello/<str:name>/becomeshallo/<str:name>/; a translation that changes or drops the converter breaks the route. -
Translate URLs only inside
i18n_patterns. A carelessly translated segment outside it can collide with an untranslated pattern. -
Name your routes and always
reversethem. Hard-coded paths do not follow the language. -
Link to another language’s version with the
languagetag.{% language code %}{% url 'catalog:about' %}{% endlanguage %}(shown in Switching the language inside a template:{% language %}) builds the link in that language, because a plainurltag always uses the current one. -
Switch the current URL programmatically with
translate_url.django.urls.translate_url(url, lang_code)returns the same page in another language (or the URL unchanged if it cannot); theset_languageview uses it to rewritenext. It resolves the URL under the currently active language, so with Dutch activetranslate_url("/nl/over/", "en")is/en/about/, while with English active the same call returns/nl/over/unchanged (checked in the test suite).
A test with override_settings(ROOT_URLCONF="config.urls_noprefix") shows prefix_default_language=False:
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
]
urlpatterns += i18n_patterns(
path("", include("catalog.urls")),
prefix_default_language=False,
)
@override_settings(ROOT_URLCONF="config.urls_noprefix")
def test_default_language_unprefixed(self):
with translation.override("en"):
assert reverse("catalog:about") == "/about/"
with translation.override("nl"):
assert reverse("catalog:about") == "/nl/over/"
# without a prefix the default language is used, whatever the browser asks for
assert self.client.get("/language/", headers={"accept-language": "nl"}).json()["request_language"] == "en"
assert self.client.get("/nl/language/").json()["request_language"] == "nl"
Note the third assertion: with prefix_default_language=False, an unprefixed URL always means the default language and the
Accept-Language header is ignored for it, because the middleware sets the language to LANGUAGE_CODE when no prefix is present.
Sites that want "redirect to my browser’s language on first visit" need the default prefix_default_language=True.
The language switcher: set_language
Django ships a view, django.views.i18n.set_language, that stores the choice in the language cookie and redirects. Include the
URLconf django.conf.urls.i18n outside i18n_patterns, which mounts it at setlang/:
path("i18n/", include("django.conf.urls.i18n")), # /i18n/setlang/
The view expects a POST with a language field and an optional next field (a GET or POST parameter). It:
-
checks
languagewithcheck_for_languageand, if good, sets the cookie named byLANGUAGE_COOKIE_NAMEwith the otherLANGUAGE_COOKIE_*attributes; -
validates
nextwithurl_has_allowed_host_and_scheme(same host, safe scheme); an unsafe or missingnextfalls back to theReferer, then to/, for requests that accept HTML; a client that does not accept HTML and sent nonextgets204 No Content; -
rewrites
nextinto the new language withtranslate_url, so a visitor on/en/about/who chooses Dutch lands on/nl/over/; -
never changes state on a GET: a GET only redirects.
The form in the base template of the scratch project:
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
<input name="next" type="hidden" value="{{ redirect_to }}">
<select name="language">
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
<button type="submit">{% translate "Go" %}</button>
</form>
redirect_to is a template variable the view provides (request.path here). A full browser-less round trip with curl, fetching a
CSRF token first, shows the redirect and the cookie:
$ curl -si -b jar.txt -c jar.txt -d "language=nl&next=/en/about/&csrfmiddlewaretoken=<token>" \
http://127.0.0.1:51731/i18n/setlang/ | grep -i "^HTTP\|^Location\|^Set-Cookie: django_language"
HTTP/1.1 302 Found
Location: /nl/over/
Set-Cookie: django_language=nl; Path=/; SameSite=Lax
The test suite adds three checks: the cookie carries the SameSite=Lax attribute from LANGUAGE_COOKIE_SAMESITE; a GET to
/i18n/setlang/?language=pl&next=/en/about/ sets no cookie; and a POST whose next is https://evil.example/ is refused (the
Location falls back to /).
Setting the language yourself
When the preference comes from elsewhere (a user profile, a single-sign-on claim), activate it for the request and persist it in the
cookie so the next request finds it. activate affects only the current thread or task:
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils import translation
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
@require_POST
def remember_language(request):
"""Persist a preference that came from somewhere other than the language form."""
code = request.POST.get("language", "")
if not translation.check_for_language(code):
return HttpResponseBadRequest("Unsupported language")
with translation.override(code):
response = HttpResponse(_("Welcome to the Bookshelf."))
response.set_cookie(
settings.LANGUAGE_COOKIE_NAME,
code,
max_age=settings.LANGUAGE_COOKIE_AGE,
samesite=settings.LANGUAGE_COOKIE_SAMESITE,
)
return response
The test posts language=nl and receives Welkom bij de Boekenplank. with django_language=nl in the cookies; a 600-character code
returns 400 (check_for_language rejects codes longer than 500 characters), and a GET returns 405.
Do not store the language in the session. Older tutorials set request.session["_language"] or
request.session[translation.LANGUAGE_SESSION_KEY]. Django 3.0 stopped reading the language from the session in
LocaleMiddleware and stopped preserving it across logout(); Django 4.0 removed the code that set_language used to store it.
The cookie is the only supported store.
Translating outside views and templates
The translation functions are not tied to the request. A background task, a management command, a signal handler or an e-mail
builder can pick any language that has a catalogue. The three functions of interest are get_language() (the language of the
current thread or task), activate(code) (switch it) and check_for_language(code) (is there a catalogue). Because activation is
per thread, always restore the previous language. The context manager override does that for you (and also works as a
function decorator):
from django.utils import translation
def welcome_in(language):
"""Translate outside a request without leaking the language."""
with translation.override(language):
return translation.gettext("Welcome to the Bookshelf.")
The test asserts welcome_in("nl") == "Welkom bij de Boekenplank." and welcome_in("pl") == "Witamy przy Półce z książkami.". The
reference page for django.utils.translation lists the full set of helpers:
| Function | Purpose |
|---|---|
|
Translate now: plain, with context, with plural, with both. |
|
Translate when the value is used. |
|
Mark for extraction, return unchanged. |
|
Install that language’s translation for the current thread or task. |
|
Remove the activated translation; calls fall back to the default (the |
|
Install an empty translation, so every string appears in the source language. Used internally when a |
|
Context manager and decorator: activate on entry, restore (or, with |
|
The active code; |
|
|
|
The language the detection algorithm would choose for a request; with |
|
The best language in |
|
Whether a catalogue exists for the code; |
|
|
|
Turns a Django template into a form |
Source: Django: django.utils.translation
Management commands, tasks and mail
Since Django 2.1 management commands run with the language that is active, no longer with translations deactivated. If a command must
write untranslated text (for example into the database), decorate the work with @no_translations; otherwise pick a language with
override. The same applies to anything that runs outside a request: a Celery or django.tasks worker, a cron-driven script, a
signal receiver. Wrap the rendering of a user-facing message in override(<that user’s language>).
from django.core.management.base import BaseCommand, no_translations
from django.utils import translation
from django.utils.translation import gettext as _
class Command(BaseCommand):
help = "Print the welcome line in one or all languages."
def add_arguments(self, parser):
parser.add_argument("--lang", default=None)
parser.add_argument("--raw", action="store_true", help="Run with translations deactivated.")
def handle(self, *args, **options):
if options["raw"]:
return self.handle_raw()
lang = options["lang"]
if lang:
with translation.override(lang):
self.stdout.write(_("Welcome to the Bookshelf."))
else:
self.stdout.write(_("Welcome to the Bookshelf.") + " [" + str(translation.get_language()) + "]")
@no_translations
def handle_raw(self):
self.stdout.write(_("Welcome to the Bookshelf.") + " [" + str(translation.get_language()) + "]")
$ python manage.py welcome
Welcome to the Bookshelf. [en]
$ python manage.py welcome --lang nl
Welkom bij de Boekenplank.
$ python manage.py welcome --lang pl
Witamy przy Półce z książkami.
$ python manage.py welcome --raw
Welcome to the Bookshelf. [None]
[None] is the signature of deactivate_all(): get_language() returns None and every gettext returns its argument. The
decorator cannot be used by commands that run without configured settings, because deactivation needs them.
Synchronous and asynchronous code
The active translation is stored in an asgiref.local.Local (see trans_real.py in the Django source), which is thread-local
under WSGI and task-local under ASGI. Consequences:
-
Under WSGI each worker thread has one active language at a time;
LocaleMiddlewaresets it at the start of every request, so sequential requests on one thread never see each other’s language, but code that runs between requests (a signal, a test) sees whatever the last request left. -
Under ASGI, concurrent requests on one event loop each have their own language even though they interleave.
LocaleMiddlewareis async-capable, so the chain stays asynchronous;awaitpoints inside a view do not change the language. -
The translation functions themselves are plain synchronous functions (no I/O after the catalogue is loaded), so call them directly from
async defviews; there is noagettext. -
The first use of a language loads its
.mofiles from disk. In a latency-sensitive async deployment warm the catalogue at start-up (for exampletranslation.activate("nl")in anAppConfig.ready()for each language, thendeactivate()) rather than paying for it in the first request.
The async view and the test that proves it: fifteen requests in three languages run concurrently, each view sleeps 50 ms (so the requests interleave) and each still answers in its own language.
async def async_greeting(request):
# Translation state is task-local under ASGI, so concurrent requests do not leak.
await asyncio.sleep(0.05) # let the other request run in between
return HttpResponse(_("Welcome to the Bookshelf.") + "|" + translation.get_language())
Source: Django: Asynchronous support
class ConcurrentAsyncTests(SimpleTestCase):
async def test_concurrent_requests_keep_their_own_language(self):
async def fetch(lang):
r = await AsyncClient().get("/async/", headers={"accept-language": lang})
return r.content.decode()
results = await asyncio.gather(*(fetch(lang) for lang in ["nl", "pl", "en"] * 5))
assert results == [
"Welkom bij de Boekenplank.|nl",
"Witamy przy Półce z książkami.|pl",
"Welcome to the Bookshelf.|en",
] * 5
JavaScript translations
JavaScript has no gettext, cannot read .po or .mo files and should receive only the strings it needs. Django solves the three
problems with a view that serves a JavaScript library whose functions mimic the gettext API, plus the translated strings.
JavaScriptCatalog
django.views.i18n.JavaScriptCatalog is a class-based view with two attributes:
| Attribute | Meaning |
|---|---|
|
The message-file domain to read. Default |
|
A list of app names ( |
urlpatterns += i18n_patterns(
path("jsi18n/", JavaScriptCatalog.as_view(packages=["catalog"]), name="javascript-catalog"),
path("jsoni18n/", JSONCatalog.as_view(packages=["catalog"]), name="json-catalog"),
)
Create the strings with the djangojs domain, not with -e js, and compile as usual:
$ django-admin makemessages -d djangojs -l nl
$ django-admin compilemessages
msgid "Added to cart"
msgstr "Toegevoegd aan winkelwagen"
msgid "%s book in your cart"
msgid_plural "%s books in your cart"
msgstr[0] "%s boek in je winkelwagen"
msgstr[1] "%s boeken in je winkelwagen"
Load the script before the code that uses it. The template resolves its URL by name, so the language prefix is right:
<p id="js-demo"></p>
<script src="{% url 'javascript-catalog' %}"></script>
<script>
const count = 3;
const line = interpolate(
ngettext("%s book in your cart", "%s books in your cart", count),
[count]
);
document.getElementById("js-demo").textContent = gettext("Added to cart") + " - " + line;
</script>
The page /nl/over/ renders the <script src="/nl/jsi18n/"> tag (checked by a test). The catalogue is plain JavaScript, so it can
be run outside a browser; this Node script loads the file served at /nl/jsi18n/ and calls the functions:
// Run the generated catalogue outside a browser: node tools/check_catalog.js jsi18n_nl.js
globalThis.document = { write() {} };
require("vm").runInThisContext(require("fs").readFileSync(process.argv[2], "utf8"));
console.log(gettext("Added to cart"));
console.log(interpolate(ngettext("%s book in your cart", "%s books in your cart", 3), [3]));
console.log(ngettext("%s book in your cart", "%s books in your cart", 1));
console.log(gettext("Not in the catalogue"));
console.log(pluralidx(0), pluralidx(1), pluralidx(2));
console.log(get_format("DECIMAL_SEPARATOR"), get_format("DATE_FORMAT"));
console.log(interpolate("Total: %(total)s of %(count)s", { total: 50, count: 10 }, true));
$ node tools/check_catalog.js jsi18n_nl.js
Toegevoegd aan winkelwagen
3 boeken in je winkelwagen
%s boek in je winkelwagen
Not in the catalogue
1 0 1
, j F Y
Total: 50 of 10
Notes on the output: an unknown string comes back unchanged; ngettext returns the format string and interpolate fills the
placeholders (singular %s boek is returned uninterpolated because ngettext only chooses a form); and in 6.1.1 pluralidx
returns the numbers 1 or 0 where the official page describes true and false, so treat the result as truthy.
| Function | Behaviour |
|---|---|
|
Translate, like the Python function. |
|
Pick the plural form for |
|
Fill |
|
The format string for one of |
|
Returns its argument; a placeholder for later translation. |
|
Contextual variants. |
|
Whether |
JSONCatalog
For a client-side library that brings its own i18n machinery (i18next, FormatJS, a framework’s loader) use JSONCatalog. It takes the
same domain and packages attributes and returns JSON with the catalogue, the locale formats and the plural expression. The scratch
project’s endpoint returned:
{
"catalog": {
"Added to cart": "Toegevoegd aan winkelwagen",
"%s book in your cart": ["%s boek in je winkelwagen", "%s boeken in je winkelwagen"]
},
"formats": {
"DATE_FORMAT": "j F Y",
"DECIMAL_SEPARATOR": ",",
"THOUSAND_SEPARATOR": ".",
"FIRST_DAY_OF_WEEK": 1,
"…": "…"
},
"plural": "(n != 1)"
}
(The real response lists every format name and the input-format arrays; they are elided with … here.) A plural entry is an array
indexed by the plural form, and plural is the gettext expression to evaluate for a count.
Caching the catalogue
Both views rebuild the catalogue from .mo files on every request. The output is constant between deployments, so cache it on the
server, on the client, or build it at deploy time. Server-side with cache_page and a key prefix that changes when translations
change; client-side with a conditional-GET decorator (or ConditionalGetMiddleware ETags):
from django.conf.urls.i18n import i18n_patterns
from django.urls import path
from django.utils import timezone
from django.views.decorators.cache import cache_page
from django.views.decorators.http import last_modified
from django.views.i18n import JavaScriptCatalog
# The value must change when the translations change; a deploy id is a good source.
CATALOG_VERSION = "2026-10-03-1"
last_modified_date = timezone.now() # changes on every restart of the application server
urlpatterns = i18n_patterns(
path(
"jsi18n/",
cache_page(86400, key_prefix=f"jsi18n-{CATALOG_VERSION}")(
JavaScriptCatalog.as_view(packages=["catalog"])
),
name="javascript-catalog",
),
path(
"jsi18n-conditional/",
last_modified(lambda request, **kwargs: last_modified_date)(
JavaScriptCatalog.as_view(packages=["catalog"])
),
name="javascript-catalog-conditional",
),
)
With a local-memory cache and a trimmed middleware list, the test checks that the second request is served from the cache (the
Cache-Control header carries max-age=86400) and that a request with If-Modified-Since set to the Last-Modified value of the
conditional view gets 304. The most radical option is to pre-generate the catalogue during the build and serve it as a static
file; the django-statici18n package does that.
Translating database content
Django translates interface strings. It does not translate text stored in model fields: a book’s title or description, a genre name, a blog post. The official page says so and points to community packages. The three realistic designs, in increasing weight:
| Approach | How it stores data | Strengths | Costs |
|---|---|---|---|
One column per language (hand-written) |
|
No dependency, trivially queryable, easy to reason about |
A migration per added language; every query that shows the field must choose the column |
|
Adds one real column per language to the model’s table ( |
Admin integration, transparent attribute access, fallbacks, easy joins and filters |
Migrations per language; the registered fields change the model’s shape |
|
A separate translation table, one row per language, behind |
Languages are data, not schema: add a language without a migration; many languages scale well |
An extra join; queries must use |
A hand-written translated field
For a handful of short labels, plain columns are enough. The Genre model above has name_en and name_nl, and one method that
chooses the column for a language with an explicit fallback to English:
class Genre(models.Model):
code = models.SlugField(unique=True)
name_en = models.CharField(_("name (English)"), max_length=100)
name_nl = models.CharField(_("name (Dutch)"), max_length=100, blank=True)
def name_for(self, language):
"""Return the name in `language`, falling back to English."""
return getattr(self, f"name_{language[:2]}", "") or self.name_en
with translation.override("nl"):
assert Genre.objects.get().name_for("nl") == "Sciencefiction"
assert Genre.objects.get().name_for("pl") == "Science fiction" # no Polish column: falls back
This pattern stops being pleasant around three languages or ten fields. That is when a package earns its dependency.
django-modeltranslation
The package keeps your model as it is and adds one column per language to the registered fields. You register the model in a
translation.py that the package auto-discovers in each app:
# catalog/translation.py -- from the package documentation, not run in the scratch project
from modeltranslation.translator import TranslationOptions, register
from .models import Book
@register(Book)
class BookTranslationOptions(TranslationOptions):
fields = ("title",)
The documented setup is: add modeltranslation to INSTALLED_APPS (before django.contrib.admin if you use the admin integration),
keep USE_I18N = True, list your languages in LANGUAGES (the first one is the default language), create translation.py, then
makemigrations and migrate. Related settings include MODELTRANSLATION_DEFAULT_LANGUAGE and
MODELTRANSLATION_FALLBACK_LANGUAGES. For the admin, derive your ModelAdmin from modeltranslation.admin.TranslationAdmin. The
update_translation_fields command copies existing values into the default language’s new column.
django-parler
Parler stores translations in a separate table. The model derives from TranslatableModel and declares its translatable fields in a
TranslatedFields block:
# from the package documentation, not run in the scratch project
from django.db import models
from django.utils.translation import gettext_lazy as _
from parler.models import TranslatableModel, TranslatedFields
class BookText(TranslatableModel):
translations = TranslatedFields(
title=models.CharField(_("Title"), max_length=200),
)
def __str__(self):
return self.title
Source: django-parler: Quick start guide
You add parler to INSTALLED_APPS, filter with MyModel.objects.translated(title=…) or active_translations(…), and set the
language of an instance with set_current_language(…); PARLER_LANGUAGES and PARLER_DEFAULT_LANGUAGE_CODE configure fallbacks.
The admin class is parler.admin.TranslatableAdmin.
State of the packages on 2026-10-03
| Package | Latest on PyPI | Declared support | Remarks |
|---|---|---|---|
django-modeltranslation |
0.20.6 (2026-09-07) |
no Django classifiers on PyPI; requires Python 3.10 or newer (below 4) |
Its public documentation’s compatibility matrix stops at Django 4.2 and is labelled "dev"; test against your Django version. |
django-parler |
2.4 (2026-05-14) |
classifiers for 5.1, 5.2, 6.0 |
The documentation site still shows version 2.3 (built in 2022) and an example with |
Neither package was installed in the scratch project, so the code above is reproduced from their documentation and has not been run
against Django 6.1.1. Check each package’s release notes for 6.1 support before adopting it, and run your own test suite. Django
Packages (https://djangopackages.org/grids/g/i18n/) lists further options, and the official ecosystem page keeps a curated
internationalization section.
Pitfalls shared by all designs: search and ordering work on one language’s column (collation decides sort order per language);
unique constraints must be per language; fallbacks should be explicit, not accidental; and the admin needs a way to show the
other languages (tabs, in both packages). Content that is translated by humans also needs a workflow: who fills title_nl, and what do
visitors see until they do?
Testing translated code
-
Use
translation.override(oractivateplus cleanup) in tests. A request made with the test client leaves its language active on the thread. The scratch project showed it: a test class that ran after the middleware tests foundnlactive and failed anassert str(lazy) == "Welcome to the Bookshelf."until itssetUpactivatedenand a cleanup deactivated it. -
Pass the header with
headers=.self.client.get("/", headers={"accept-language": "nl"}); the async client accepts the same form. The olderHTTP_ACCEPT_LANGUAGE="nl"spelling still works. -
Set the language cookie with
self.client.cookies["django_language"] = "pl"to test the cookie branch. -
Test URLs both ways:
reverseunderoverride,resolveof the translated path, and a 404 for the untranslated one. -
Compile before testing. Without
.mofiles every assertion on translated text fails; in CI runcompilemessagesbefore the tests (or ship the.mofiles). -
Test plural forms with numbers that differ in every rule (1, 2, 5, 22 for Polish).
class TranslationApiTests(SimpleTestCase):
def setUp(self):
# A test client request leaves its language active on this thread.
translation.activate("en")
self.addCleanup(translation.deactivate)
def test_welcome_in(self):
assert welcome_in("nl") == "Welkom bij de Boekenplank."
assert welcome_in("pl") == "Witamy przy Półce z książkami."
Source: Django: Testing tools
The suite passes as a whole:
$ python manage.py test
Found 34 test(s).
System check identified no issues (0 silenced).
..................................
----------------------------------------------------------------------
Ran 34 tests in 0.2s
OK
Pitfalls checklist
| Pitfall | What to do |
|---|---|
Strings that never show up in the |
Keep the literal inside the call ( |
The sentence is built with an f-string or by concatenation |
Use one translatable sentence with named placeholders; word order differs per language. |
Eager |
Use |
Plurals decided with |
Use |
Wrong middleware position |
|
Translated text that changes after a deploy but not after a compile |
Compile |
|
Restrict it to what you ship, and keep the list in sync with the |
Language leaks between requests in tests or scripts |
Use |
Caching pages that differ by language |
Rely on the |
JavaScript shows English |
The catalogue view must be inside |
URLs translated outside |
Only translate path segments inside the prefixed block, to avoid collisions. |
|
UTF-8 without BOM only. |
|
Write |
Language chosen through a GET link |
|
Performance and deployment notes
-
USE_I18N = Falselets Django skip loading the translation machinery; only do it for sites that will never translate, becausegettextthen returns its argument andLocaleMiddlewarehas nothing to select. (WithFalse,i18n_patternsalso returns its patterns without a prefix.) -
The catalogue of a language is loaded on first use and then kept in memory for the life of the process; the cost is a one-off file read per language and per worker.
-
Compile messages in the image build, restart workers after a deploy, and cache the JavaScript catalogue (see Caching the catalogue).
-
Behind a CDN, either put the language in the URL or make sure the CDN varies on
Accept-Languageand on the language cookie; otherwise the first visitor’s language is served to everybody. -
The
Content-Languageheader and thelangattribute on<html>help search engines and assistive technology; for SEO also publishhreflangalternates (thelanguagetemplate tag makes the per-language URLs easy to build).
Where the books are outdated
The four books consulted for this section date from Django 1.11 to 5.x. For internationalization, watch for these things when you follow an older tutorial (the listings above show the current form of each):
| Older advice | Current practice | Since |
|---|---|---|
|
|
3.0 / 4.0 |
|
|
3.1 |
|
The class-based |
long gone |
Storing the language in |
The language cookie ( |
3.0 / 4.0 |
|
|
2.0 / 4.0 |
Management commands run with translations deactivated, or |
Commands use the active locale; use |
2.1 |
|
Formatting is always localized; the setting was removed. |
5.0 |
An English-only |
Always give explicit lazy |
long-standing advice |
The javascript_catalog() row is stated from absence: those functions are not in the 6.1 documentation. Version numbers in the table
come from the release notes for 2.1, 3.0, 3.1, 4.0 and 5.0.
What changed recently
| Version | Change that touches translation |
|---|---|
3.0 |
|
3.1 |
|
3.2 |
|
4.0 |
|
4.1 |
|
4.2 |
|
5.0 |
|
5.2 (LTS) |
Minimum gettext raised from 0.15 to 0.19. |
6.0 |
Haitian Creole added; no other change to the translation workflow. |
6.1 |
|
Nothing about the gettext workflow itself changed in 6.0 or 6.1: the code on this page is the same on 5.2 LTS and on 6.1, apart from
the minimum gettext version. For the long view, i18n_patterns, LocaleMiddleware and JavaScriptCatalog have been stable for years.