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

gettext (usually imported as _) from django.utils.translation.

Marking strings in Python

Translate a literal at import time (model fields, form fields, settings, URL patterns)

gettext_lazy, ngettext_lazy, pgettext_lazy; format_lazy to combine them.

Lazy translation: translating late

Pluralize correctly

ngettext(singular, plural, count); never your own if count == 1.

Plurals: ngettext

Disambiguate two meanings of one English word

pgettext("context", "May") or {% translate "May" context "month name" %}.

Contextual markers: pgettext and npgettext

Translate template text

{% translate "…" %} for a constant, {% blocktranslate %} for text with variables or plurals.

Marking strings in templates

Extract and compile translations

django-admin makemessages -l nl, translate the .po, then django-admin compilemessages. Needs GNU gettext.

Message files: makemessages and compilemessages

Choose the language per request

LocaleMiddleware, placed after SessionMiddleware and before CommonMiddleware.

Choosing the language per request: LocaleMiddleware

Put the language in the URL (/nl/…)

i18n_patterns() in the root URLconf; optionally translate the path segments with gettext_lazy.

Translated URLs: i18n_patterns

Let the visitor switch language

A POST form to django.conf.urls.i18n’s `set_language view (language cookie).

The language switcher: set_language

Translate strings in JavaScript

JavaScriptCatalog (or JSONCatalog) plus makemessages -d djangojs.

JavaScript translations

Run code in another language without a request

with translation.override("nl"):.

Translating outside views and templates

Translate database content (a book’s title, a genre name)

Not built in. A hand-written field per language, django-modeltranslation or django-parler.

Translating database content

Turn translation off for speed

USE_I18N = False.

The settings that matter

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 ll or ll_CC with an underscore: it, de_AT, pt_BR, sr_Latn. This is the form of the directory names under locale/.

Language code

The same idea written with a dash, as browsers send it in the Accept-Language header: it, de-at, pt-br. This is the form used in LANGUAGE_CODE, in LANGUAGES and in URL prefixes. to_locale("pt-br") gives pt_BR.

Message file

A plain-text .po file for one language: the source strings (msgid) and their translations (msgstr).

Compiled catalogue

The binary .mo file produced from a .po by msgfmt; this is what Django loads at run time.

Domain

django for Python and template strings, djangojs for JavaScript strings. The split keeps the JavaScript catalogue small.

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.

flowchart LR A[Mark strings in Python and templates] --> B[makemessages] B --> C[locale/nl/LC_MESSAGES/django.po] C --> D[Translator fills in msgstr] D --> E[compilemessages] E --> F[django.mo] F --> G[LocaleMiddleware activates a language] G --> H[gettext looks the string up]

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.

config/settings.py (excerpt)
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.

Table 1. Every i18n setting
Setting Default Purpose

USE_I18N

True

Enables the translation machinery. False lets Django skip loading it (a small optimisation); startproject writes True.

LANGUAGE_CODE

'en-us'

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 ({% localize off %}).

LANGUAGES

every language Django ships

The languages that may be selected. Restrict it to the languages you actually translate. Mark the names with gettext_lazy, never plain gettext (a plain call at settings-import time is a circular import).

LANGUAGES_BIDI

the right-to-left languages Django knows

Codes written right to left; drives get_language_bidi() and the LANGUAGE_BIDI template variable. Rarely changed.

LOCALE_PATHS

[]

Extra directories searched for <language>/LC_MESSAGES/django.mo. Highest precedence; first entry wins.

FORMAT_MODULE_PATH

None

A package with per-locale formats.py modules that override Django’s date, time and number formats.

LANGUAGE_COOKIE_NAME

'django_language'

Name of the cookie that remembers the visitor’s choice.

LANGUAGE_COOKIE_AGE

None

Cookie lifetime in seconds; None means "until the browser closes". Set a long value or the preference is forgotten.

LANGUAGE_COOKIE_DOMAIN

None

Set to e.g. "example.com" to share the cookie across subdomains.

LANGUAGE_COOKIE_PATH

'/'

Path of the cookie; useful when several Django projects share a host name.

LANGUAGE_COOKIE_HTTPONLY

False

Hide the cookie from JavaScript. Leave False if client-side code reads it.

LANGUAGE_COOKIE_SAMESITE

None

The SameSite attribute. 'Lax' is a safe explicit choice. Since Django 3.1 'None' is also accepted.

LANGUAGE_COOKIE_SECURE

False

Mark the cookie Secure so browsers send it only over HTTPS. Turn it on in production.

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 _:

catalog/views.py (excerpt)
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 _: gettext and gettext_lazy. The others (ngettext, pgettext, …​) must be imported by name because xgettext (the extractor) is configured with their argument layout.

  • Django deliberately does not install () into the builtins as the standard library’s gettext.install() would. The underscore means "the previous result" in the interactive shell and in doctests, and a global would 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, yet makemessages never sees the literal, so it never reaches a .po file. 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 %s does not.

  • Never use an f-string. _(f"Welcome {name}") interpolates before gettext is 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-format above the call.

  • Comments for translators. A comment that begins with the keyword Translators on the line above the call is copied into the .po file 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.

catalog/views.py (excerpt)
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:

catalog/locale/pl/LC_MESSAGES/django.po (excerpt)
"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."
catalog/tests.py (excerpt)
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_name or verbose_name_plural with if count == 1 is wrong for every language whose rule is not "one versus many". Put the model’s verbose_name inside both sentences instead.

  • Use the same placeholder names in the singular and the plural literal. A singular using %(name)s and a plural using %(plural_name)s makes compilemessages fail 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_lazy accepts a key name as its third argument and looks the number up in the dictionary used for the later % interpolation:

catalog/forms.py (illustrative)
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.

catalog/views.py (excerpt)
from django.utils.translation import pgettext

month = pgettext("month name", "May")
verb = pgettext("verb", "May")
catalog/locale/nl/LC_MESSAGES/django.po (excerpt)
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.

catalog/views.py (excerpt)
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_name and help_text, relation verbose_name, and Meta.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

  • LANGUAGES names and URL patterns

  • the description argument of @admin.display and @admin.action

  • module-level constants

catalog/models.py (excerpt)
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 str in Django code, but arbitrary Python (the requests library, json.dumps, a database driver) may not accept it. Convert with str(value) at the boundary, and only there, because str() freezes the current language.

  • str.format() does not work when the format string or an argument is lazy. Use django.utils.text.format_lazy, which creates another lazy object that formats only when finally rendered:

catalog/views.py (excerpt)
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); then to_lower_lazy(_("My STRING!")) stays lazy.

  • Lazy and non-lazy are not interchangeable. Calling the eager gettext in 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).

Table 2. Options of the translate tag
Form Effect

{% translate "Search" %}

Translate the constant.

{% translate label %}

Resolve label, then translate the result.

{% translate "Search" noop %}

Do the lookup of variables but skip the translation. Stub for text that will be translated later; the literal is still extracted.

{% translate "Search" as s %}

Store the translation in s instead of printing it, to reuse it or pass it to another tag or filter.

{% translate "May" context "month name" %}

Contextual marker (msgctxt), like pgettext.

catalog/templates/catalog/home.html (excerpt)
{% 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.

Table 3. Options of the blocktranslate tag
Option Effect

with a=expr b=expr2

Bind expressions to names used inside the block. (The older with expr as a and expr2 as b form still works.)

count counter=expr

Bind the number that selects the plural form; add {% plural %} between the singular and plural text.

trimmed

Strip the newlines and indentation, joining lines with one space, so that indenting the block does not pollute the msgid.

context "text"

Contextual marker. In the .po file the entry gets a msgctxt.

asvar name

Store the result in name instead of printing it. Since Django 4.2 the stored value is marked safe for HTML output.

catalog/templates/catalog/home.html (excerpt)
{% 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.

catalog/locale/nl/LC_MESSAGES/django.po (excerpt)
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 ngettext applies to a plural block with extra with variables: it compiles to an ngettext call, 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.

  • asvar is 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 msgstr is trusted markup, so translators can include <em>, and a stray double quote renders unchanged. Variables used inside the block are still escaped: rendering the sentence Hello, {{ name }}! with name equal to <b> produced Hello, <b>! in the test. Treat the .po files 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:

catalog/templates/catalog/home.html (excerpt)
{% 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

{% get_available_languages as LANGUAGES %}

List of (code, name) pairs from LANGUAGES, with names translated into the active language.

{% get_current_language as LANGUAGE_CODE %}

The active code as a string, for example nl.

{% get_current_language_bidi as LANGUAGE_BIDI %}

True for a right-to-left language. Use it for dir="rtl" on <html>.

{% get_language_info for "pl" as lang %}

A dictionary-like object: lang.code, lang.name, lang.name_local, lang.name_translated, lang.bidi.

{% get_language_info_list for LANGUAGES as languages %}

The same information for a list of (code, name) pairs or a list of plain codes.

{{ code|language_name }}, language_name_local, language_name_translated, language_bidi

Filters for one code: "de" gives German, Deutsch, the name in the active language, and False.

i18n context processor

With django.template.context_processors.i18n enabled every RequestContext has LANGUAGES, LANGUAGE_CODE and LANGUAGE_BIDI without a tag.

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.

Table 4. Options of makemessages
Option Meaning

--locale LOCALE, -l

Locale(s) to process; repeat the option for several.

--exclude LOCALE, -x

Locale(s) to skip.

--all, -a

Update the message files of all existing languages.

--extension EXTENSIONS, -e

File extensions to scan. Default html, txt, py (or js with -d djangojs). Comma-separate or repeat. Do not use -e js for JavaScript; use the djangojs domain.

--domain DOMAIN, -d

django (default: .py, .html, .txt) or djangojs (.js).

--symlinks, -s

Follow symbolic links to directories.

--ignore PATTERN, -i

Glob pattern to skip, repeatable. Defaults ignored: CVS, ., ~, *.pyc.

--no-default-ignore

Switch the default ignore patterns off.

--no-wrap

Do not break long message lines.

--no-location

Omit the #: file:line comments. Makes translators' lives harder.

--add-location [full|file|never]

full (default) keeps file and line, file omits the line number, never is --no-location.

--no-obsolete

Remove obsolete entries (strings no longer in the source) from the .po.

--keep-pot

Keep the temporary .pot files; useful to debug extraction failures.

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.

catalog/locale/nl/LC_MESSAGES/django.po (excerpt)
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>/"
  • msgid is the source text and must not be edited; msgstr is 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-Forms line defines how many msgstr[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.

  • fuzzy entries (a guess msgmerge made from a similar string) are skipped by compilemessages unless you pass --use-fuzzy. Review and remove the flag rather than shipping fuzzy text.

  • #. Translators: … lines are the comments you wrote for translators; #: path:line lines 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"
Table 5. Options of compilemessages
Option Meaning

--locale LOCALE, -l

Locale(s) to compile; default all.

--exclude LOCALE, -x

Locale(s) to exclude.

--use-fuzzy, -f

Include fuzzy entries in the compiled file.

--ignore PATTERN, -i

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:

catalog/management/commands/makemessages.py
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:

  1. The directories in LOCALE_PATHS, first entry first.

  2. The locale/ directory of each app in INSTALLED_APPS, first app first.

  3. 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:

locale/nl/LC_MESSAGES/django.po
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:

  1. Put it in the first few entries.

  2. It must come after SessionMiddleware (the locale logic may read session-backed data) and before CommonMiddleware (because CommonMiddleware needs an activated language to resolve the requested URL, and with i18n_patterns the URL depends on the active language).

  3. If you use the cache middleware, put LocaleMiddleware after it.

sequenceDiagram participant C as Client participant S as SessionMiddleware participant L as LocaleMiddleware participant Co as CommonMiddleware participant R as URL resolver participant V as View and template C->>S: GET /language/ with Accept-Language S->>L: get_response(request) L->>L: choose language, activate it L->>Co: get_response(request) Co->>R: resolve path under the active language R->>V: call view V-->>L: response (strings translated in the active language) L-->>C: Content-Language header, Vary header, or a redirect to the prefixed URL
config/settings.py (excerpt)
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:

  1. The language prefix in the URL path, but only if the root URLconf uses i18n_patterns (see Translated URLs: i18n_patterns).

  2. The language cookie, whose name is LANGUAGE_COOKIE_NAME (default django_language).

  3. The Accept-Language header that the browser sends. Django tries each language in the header, in priority order, until one has available translations.

  4. 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-at selects de.

  • Only languages listed in LANGUAGES can be selected, whether the preference comes from the URL, the cookie or the header. Restrict LANGUAGES to the languages you really ship; otherwise a visitor whose header says fr is served in French and every string you never translated stays English in the middle of Django’s own French messages.

  • A language in LANGUAGES must also have a catalogue Django can load (check_for_language); a code that is listed but has no .mo anywhere 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-Language to the active language if the view did not set one.

  • Adds Accept-Language to the Vary header, unless the URL carried a language prefix (the URL already distinguishes the variants). In the curl output above, the prefixed /nl/language/ response has no Vary: Accept-Language, while an unprefixed response does.

  • For a 404 on an unprefixed path, when i18n_patterns is in use with prefix_default_language=True, it tries the same path with the language prefix and, if that path resolves (also trying to append a slash when APPEND_SLASH is on), redirects there. The redirect carries Vary: Accept-Language, Cookie so shared caches do not serve one visitor’s redirect to another. The redirect class is the attribute response_redirect_class (default HttpResponseRedirect); 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.

catalog/views.py (excerpt)
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")),
        }
    )

Pitfalls

  • Position is not optional. Placing LocaleMiddleware after CommonMiddleware makes URL resolution happen before a language is active; i18n_patterns URLs and APPEND_SLASH redirects 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 returned 404 for /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-Language for unprefixed URLs; cache_page and the per-site cache middleware honour Vary, and a CDN must be configured to as well. Prefixed URLs make caching simpler: each language has its own URL.

  • set_language and LANGUAGES are 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 in LANGUAGES, 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

config/urls.py
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.

Table 6. Rules and restrictions
Rule Why

Only in the root URLconf

Using it in an included URLconf raises ImproperlyConfigured.

prefix_default_language=False

Drops the prefix for the default language (LANGUAGE_CODE), so existing URLs of a site that adds translations do not change.

Do not include set_language inside it

The endpoint must be language independent. The scratch project keeps i18n/ outside.

Wrap JavaScriptCatalog too

If the root URLconf uses i18n_patterns, the catalogue views must also be inside it, or the catalogue is generated in the wrong language.

Avoid unprefixed routes that collide with a prefix

A route en/ outside the patterns would clash with the automatically added English prefix.

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

catalog/urls.py
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 msgid is the whole pattern string, including converters: hello/<str:name>/ becomes hallo/<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 reverse them. Hard-coded paths do not follow the language.

  • Link to another language’s version with the language tag. {% language code %}{% url 'catalog:about' %}{% endlanguage %} (shown in Switching the language inside a template: {% language %}) builds the link in that language, because a plain url tag 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); the set_language view uses it to rewrite next. It resolves the URL under the currently active language, so with Dutch active translate_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:

config/urls_noprefix.py
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:

  1. checks language with check_for_language and, if good, sets the cookie named by LANGUAGE_COOKIE_NAME with the other LANGUAGE_COOKIE_* attributes;

  2. validates next with url_has_allowed_host_and_scheme (same host, safe scheme); an unsafe or missing next falls back to the Referer, then to /, for requests that accept HTML; a client that does not accept HTML and sent no next gets 204 No Content;

  3. rewrites next into the new language with translate_url, so a visitor on /en/about/ who chooses Dutch lands on /nl/over/;

  4. never changes state on a GET: a GET only redirects.

The form in the base template of the scratch project:

catalog/templates/catalog/base.html (excerpt)
{% 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:

catalog/views.py (excerpt)
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):

catalog/views.py (excerpt)
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:

Table 7. The django.utils.translation toolbox
Function Purpose

gettext, pgettext, ngettext, npgettext

Translate now: plain, with context, with plural, with both.

gettext_lazy, pgettext_lazy, ngettext_lazy, npgettext_lazy

Translate when the value is used.

gettext_noop

Mark for extraction, return unchanged.

activate(language)

Install that language’s translation for the current thread or task.

deactivate()

Remove the activated translation; calls fall back to the default (the LANGUAGE_CODE catalogue).

deactivate_all()

Install an empty translation, so every string appears in the source language. Used internally when a blocktranslate argument fails.

override(language, deactivate=False)

Context manager and decorator: activate on entry, restore (or, with deactivate=True, deactivate) on exit. None activates the empty translation inside the block.

get_language()

The active code; None while translations are deactivated with deactivate_all() or override(None).

get_language_bidi()

True for a right-to-left language.

get_language_from_request(request, check_path=False)

The language the detection algorithm would choose for a request; with check_path=True it looks at the URL prefix first.

get_supported_language_variant(code, strict=False)

The best language in LANGUAGES for a code (es-ar gives es); raises LookupError if there is none. Codes longer than 500 characters raise too.

check_for_language(code)

Whether a catalogue exists for the code; False for codes over 500 characters.

to_locale(code)

en-us to en_US.

templatize(src)

Turns a Django template into a form xgettext understands; what makemessages uses.

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

catalog/management/commands/welcome.py
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; LocaleMiddleware sets 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. LocaleMiddleware is async-capable, so the chain stays asynchronous; await points 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 def views; there is no agettext.

  • The first use of a language loads its .mo files from disk. In a latency-sensitive async deployment warm the catalogue at start-up (for example translation.activate("nl") in an AppConfig.ready() for each language, then deactivate()) 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.

catalog/views.py (excerpt)
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())
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:

Table 8. Attributes of JavaScriptCatalog (and JSONCatalog)
Attribute Meaning

domain

The message-file domain to read. Default 'djangojs'. Strings in djangojs.po only; the Python strings of django.po are not sent.

packages

A list of app names (AppConfig.name) whose locale/ catalogues are merged, together with everything in LOCALE_PATHS (always included). Default None means all installed apps. Later packages win when the same string occurs twice, and when several catalogue views are on the page the one loaded last wins.

config/urls.py (excerpt)
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
catalog/locale/nl/LC_MESSAGES/djangojs.po
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:

catalog/templates/catalog/about.html (excerpt)
<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:

tools/check_catalog.js
// 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.

Table 9. Functions the catalogue defines
Function Behaviour

gettext(msgid)

Translate, like the Python function.

ngettext(singular, plural, count)

Pick the plural form for count using the catalogue’s rule.

interpolate(fmt, obj, named)

Fill %s placeholders from an array, or %(name)s placeholders from an object when named is true. Python-style, implemented with regular-expression substitutions, so use it sparingly.

get_format(name)

The format string for one of DATE_FORMAT, DATE_INPUT_FORMATS, DATETIME_FORMAT, DATETIME_INPUT_FORMATS, DECIMAL_SEPARATOR, FIRST_DAY_OF_WEEK, MONTH_DAY_FORMAT, NUMBER_GROUPING, SHORT_DATE_FORMAT, SHORT_DATETIME_FORMAT, THOUSAND_SEPARATOR, TIME_FORMAT, TIME_INPUT_FORMATS, YEAR_MONTH_FORMAT. Keeps client formatting consistent with server-rendered values.

gettext_noop(msgid)

Returns its argument; a placeholder for later translation.

pgettext(context, msgid), npgettext(context, singular, plural, count)

Contextual variants.

pluralidx(count)

Whether count takes the plural form; for languages with complex rules a conditional expression is generated.

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):

config/urls_cached.py
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)

name_en, name_nl on the model

No dependency, trivially queryable, easy to reason about

A migration per added language; every query that shows the field must choose the column

django-modeltranslation

Adds one real column per language to the model’s table (title_en, title_nl, …​), configured in a translation.py per app

Admin integration, transparent attribute access, fallbacks, easy joins and filters

Migrations per language; the registered fields change the model’s shape

django-parler

A separate translation table, one row per language, behind TranslatableModel and TranslatedFields

Languages are data, not schema: add a language without a migration; many languages scale well

An extra join; queries must use translated(); parler’s own model base class

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:

catalog/models.py (excerpt)
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

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 __unicode__; use __str__.

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 (or activate plus 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 found nl active and failed an assert str(lazy) == "Welcome to the Bookshelf." until its setUp activated en and a cleanup deactivated it.

  • Pass the header with headers=. self.client.get("/", headers={"accept-language": "nl"}); the async client accepts the same form. The older HTTP_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: reverse under override, resolve of the translated path, and a 404 for the untranslated one.

  • Compile before testing. Without .mo files every assertion on translated text fails; in CI run compilemessages before the tests (or ship the .mo files).

  • Test plural forms with numbers that differ in every rule (1, 2, 5, 22 for Polish).

catalog/tests.py (excerpt)
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."

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 .po file

Keep the literal inside the call (_("text")), not in a variable; use gettext_noop for constants, and the djangojs domain for .js files.

The sentence is built with an f-string or by concatenation

Use one translatable sentence with named placeholders; word order differs per language.

Eager gettext at import time

Use gettext_lazy in models, forms, settings and URL patterns; wrap in str() only at the boundary.

Plurals decided with if count == 1

Use ngettext; keep the same placeholder names in both forms.

Wrong middleware position

LocaleMiddleware after SessionMiddleware and before CommonMiddleware; after the cache middleware if you use one.

Translated text that changes after a deploy but not after a compile

Compile .mo in the build, restart workers, and keep .mo out of version control but .po in.

LANGUAGES lists languages without catalogues

Restrict it to what you ship, and keep the list in sync with the locale/ directories.

Language leaks between requests in tests or scripts

Use override, deactivate in cleanup, and activate explicitly in management commands and workers.

Caching pages that differ by language

Rely on the Vary header, or put the language in the URL with i18n_patterns.

JavaScript shows English

The catalogue view must be inside i18n_patterns if the URLconf uses them, and djangojs .mo files must exist.

URLs translated outside i18n_patterns

Only translate path segments inside the prefixed block, to avoid collisions.

.po file saved with a BOM or a non-UTF-8 encoding

UTF-8 without BOM only.

% in a message flagged as Python format

Write %% or add # xgettext:no-python-format.

Language chosen through a GET link

set_language is POST-only on purpose (a GET never changes state); use the form with CSRF.

Performance and deployment notes

  • USE_I18N = False lets Django skip loading the translation machinery; only do it for sites that will never translate, because gettext then returns its argument and LocaleMiddleware has nothing to select. (With False, i18n_patterns also 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-Language and on the language cookie; otherwise the first visitor’s language is served to everybody.

  • The Content-Language header and the lang attribute on <html> help search engines and assistive technology; for SEO also publish hreflang alternates (the language template 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

ugettext, ugettext_lazy, ungettext, ugettext_noop

gettext, gettext_lazy, ngettext, gettext_noop; the u-prefixed names were deprecated in 3.0 and removed in 4.0.

3.0 / 4.0

{% trans %}, {% blocktrans %}

{% translate %}, {% blocktranslate %} (the old names still work).

3.1

javascript_catalog() and json_catalog() function views

The class-based JavaScriptCatalog and JSONCatalog views.

long gone

Storing the language in request.session[LANGUAGE_SESSION_KEY]

The language cookie (LANGUAGE_COOKIE_NAME) set by set_language.

3.0 / 4.0

url() routes inside i18n_patterns

path() and re_path(); url() was removed in 4.0.

2.0 / 4.0

Management commands run with translations deactivated, or leave_locale_alone

Commands use the active locale; use @no_translations to opt out.

2.1

USE_L10N = True to turn formatting on

Formatting is always localized; the setting was removed.

5.0

An English-only verbose_name taken from the class name

Always give explicit lazy verbose_name and verbose_name_plural.

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

LANGUAGE_COOKIE_HTTPONLY, LANGUAGE_COOKIE_SAMESITE, LANGUAGE_COOKIE_SECURE; compilemessages --ignore; LocaleMiddleware stops reading the language from the session; ugettext* deprecated; Feed.language for syndication; new languages (Uzbek).

3.1

translate and blocktranslate tags introduced; LANGUAGE_COOKIE_SAMESITE accepts 'None'; the reloader stops watching Django’s built-in translation files; more languages.

3.2

makemessages no longer processes invalid locales that contain hyphens; the cache key no longer includes the language when USE_I18N is off and localization is on.

4.0

ugettext* removed; set_language no longer stores the language in the session; Malay translations.

4.1

i18n_patterns supports languages with both scripts and regions; makemessages does not rewrite up-to-date .po files.

4.2

makemessages accepts locales with private sub-tags; blocktranslate asvar output is marked safe; Sitemap.get_languages_for_item(); the test clients accept a headers argument (used above for Accept-Language).

5.0

USE_L10N removed (formatting is always localized); Uyghur added.

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

BLANK_CHOICE_LABEL: the default "blank" option in form choice lists is now a more accessible, translatable label (the transitional USE_BLANK_CHOICE_DASH setting restores the old dash).

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.