Part V · Infrastructure

Chapter 15: Internationalization and time zones

Django serves each request in a language and a time zone of its own, from code and templates written once: a message marked for translation is looked up as it is used, in the translations of the active language; numbers and dates are written in that language’s formats; and a datetime, stored as an instant, is shown in the current time zone. django.utils.translation, django.utils.formats and django.utils.timezone do the three, and LocaleMiddleware chooses each request’s language.

A process serves many visitors at once, and each may want a different language. The strings in the code are written once, in the language of the source, and a module is imported, and a template ordinarily compiled, once for the whole process. So a translation cannot be made when a module is imported or a template compiled. Django makes it at the last moment: a message is looked up as it is used, in the translations of whichever language is active then, and one marked lazily at import stays in the source language until something writes it out.

The active language is neither a setting nor an argument. It is a value held for the thread, or for the task in asynchronous code, which django.utils.translation.activate sets and every lookup reads. The formats in which a number or a date is written follow the same value. The time zone in which a datetime is shown is a second value, held the same way and set by django.utils.timezone.activate. A view or a template never passes either one on: whatever reads them reads them where it stands.

Two settings switch the machinery off. With USE_I18N false, the functions of django.utils.translation that translate a message or choose a language are bound, the first time each is called, to those of a module that does no translating: its gettext gives back the message it was given, and its get_language gives LANGUAGE_CODE. With USE_TZ false, Django’s datetimes carry no time zone. Formatting has no switch of its own: a value is written in the active language’s formats unless the caller asks otherwise.

The objects

Table 15.1 The objects of Django’s internationalization. Translation is in django/utils/translation/, formats in django/utils/formats.py and the locale packages under django/conf/locale/, time zones in django/utils/timezone.py.
object what it is when it acts
DjangoTranslation what it is one language’s translations: Django’s own catalogue (a compiled file of translations), each installed application’s and those of LOCALE_PATHS, layered, with the translations of LANGUAGE_CODE behind them when it acts made the first time the language is asked for, and ordinarily kept for the life of the process
the active language what it is the translations installed for the thread, a DjangoTranslation unless translation was switched off, by django.utils.translation.activate or django.utils.translation.override; django.utils.translation.get_language gives its code when it acts read by every lookup and by the formats
django.utils.translation.gettext, django.utils.translation.ngettext, django.utils.translation.pgettext what it is look a message up in the active language’s translations: as it is, with a number that chooses a plural form, or with a context that tells two uses of one string apart when it acts when called
django.utils.translation.gettext_lazy and the other lazy functions what it is return an object that holds the message and looks it up each time it is used as a string when it acts when the string is used
LocaleMiddleware what it is chooses a request’s language, from its path, a cookie, its Accept-Language header or LANGUAGE_CODE, and activates it when it acts on the way in; on the way out it adds Content-Language
the format modules what it is a locale’s formats.py, under locale/ django/conf/ or a package that FORMAT_MODULE_PATH names: how dates and numbers are written in it when it acts read by get_format for the active language, and cached
django.utils.numberformat, django.utils.dateformat what it is write a number with a locale’s separators, and a date by a format string of letters after those of PHP’s date function when it acts when a number is localized or a date written
the current time zone what it is the tzinfo installed for the thread by django.utils.timezone.activate or django.utils.timezone.override, or else the default time zone, TIME_ZONE when it acts read by django.utils.timezone.localtime, make_aware, the templates and the forms
makemessages, compilemessages what it is collect the messages of a project’s files into .po files, and compile those into .mo catalogues, with the GNU gettext programs when it acts when run

One request in three languages

The harbour office’s site lists the ships expected in port, in English, Dutch and German. Its settings install the one middleware and an application, office:

# harbour/settings.py, in part
INSTALLED_APPS = ["office"]
MIDDLEWARE = ["django.middleware.locale.LocaleMiddleware"]
LANGUAGE_CODE = "en"
LANGUAGES = [("en", "English"), ("nl", "Dutch"), ("de", "German")]
USE_TZ = True
TIME_ZONE = "Europe/Amsterdam"

The view’s title is translated lazily, and its ships arrive at instants given in UTC:

# harbour/office/views.py
from datetime import UTC, datetime

from django.shortcuts import render
from django.utils.translation import gettext_lazy as _

TITLE = _("Arrivals")
SHIPS = [
    {"name": "Endeavour", "arrives": datetime(2026, 3, 29, 9, 30, tzinfo=UTC), "tonnage": 1234.5},
    {"name": "Resolution", "arrives": datetime(2026, 3, 29, 14, 5, tzinfo=UTC), "tonnage": 980},
]


def arrivals(request):
    return render(request, "office/arrivals.html", {"title": TITLE, "ships": SHIPS})

Its template, "office/arrivals.html", writes the title, a sentence whose form depends on a count, and each ship’s time and tonnage:

{% load i18n %}<h1>{{ title }}</h1>
<p>{% blocktranslate count counter=ships|length %}One ship expected.{% plural %}{{ counter }} ships expected.{% endblocktranslate %}</p>
<ul>
{% for ship in ships %}  <li>{{ ship.name }}: {{ ship.arrives }}, {{ ship.tonnage }} t</li>
{% endfor %}</ul>

The application has a Dutch catalogue, "office/locale/nl/LC_MESSAGES/django.mo", compiled from a .po file that holds, among others, these two entries; it has no German one:

# office/locale/nl/LC_MESSAGES/django.po
msgid "Arrivals"
msgstr "Aankomsten"

msgid "One ship expected."
msgid_plural "%(counter)s ships expected."
msgstr[0] "Eén schip verwacht."
msgstr[1] "%(counter)s schepen verwacht."

A run of the example, with Django’s test client making three requests for the same page:

GET /arrivals/, with no cookie and no Accept-Language
200 OK
Content-Type: text/html; charset=utf-8
Vary: Accept-Language
Content-Language: en
<h1>Arrivals</h1>
<p>2 ships expected.</p>
<ul>
  <li>Endeavour: March 29, 2026, 11:30 a.m., 1234.5 t</li>
  <li>Resolution: March 29, 2026, 4:05 p.m., 980 t</li>
</ul>

GET /arrivals/, with Accept-Language: nl-BE,nl;q=0.9,en;q=0.8
200 OK
Content-Type: text/html; charset=utf-8
Vary: Accept-Language
Content-Language: nl
<h1>Aankomsten</h1>
<p>2 schepen verwacht.</p>
<ul>
  <li>Endeavour: 29 maart 2026 11:30, 1234,5 t</li>
  <li>Resolution: 29 maart 2026 16:05, 980 t</li>
</ul>

GET /arrivals/, with the cookie django_language=de and Accept-Language: nl
200 OK
Content-Type: text/html; charset=utf-8
Vary: Accept-Language
Content-Language: de
<h1>Arrivals</h1>
<p>2 ships expected.</p>
<ul>
  <li>Endeavour: 29. März 2026 11:30, 1234,5 t</li>
  <li>Resolution: 29. März 2026 16:05, 980 t</li>
</ul>

The view, the template and its compiled form were the same for all three. What differed was the language that LocaleMiddleware activated before the view ran. For the first request it found nothing to go on and took LANGUAGE_CODE. For the second it read the header, found "nl-be" missing from LANGUAGES, and took the more general "nl". For the third it found a language in the cookie that the view set_language sets, and did not read the header at all.

Everything on the page that depends on the language was decided as it was written. The title is a lazy translation, made when the module was imported, before any language was active; it became “Aankomsten” when the template turned it into a string. {% blocktranslate %} asked the catalogue for the form that goes with two. The times and the tonnage were localized, written in the active language’s formats, its order of day and month and its decimal comma, with its names of the months.

The hours are another matter. Each arrival is an instant, 09:30 and 14:05 in UTC, and the template showed it in the current time zone, Amsterdam’s, two hours ahead of UTC at those hours, in all three languages. Nothing chose that zone for the request: Django has no middleware that activates one, so the current zone was the default, TIME_ZONE. One value of the page passed through all three services:

Figure 15.1 How the template writes one arrival for a Dutch request: the current time zone decides the hour, and the active language the format and the month’s name.

A template writes every value through render_value_in_context, which converts an aware datetime to the current time zone with template_localtime and then localizes it. German shows the layers of a language’s translations. The office has no German catalogue, so its title and its sentence stayed English; “März” came from Django’s own German catalogue, which the translations of a language always include where Django has one.

On the way out, LocaleMiddleware.process_response set Content-Language to the active language’s code and added "Accept-Language" to "Vary". It did not deactivate the language. After the third response the thread still had German active, and code that runs later in that thread sees it, until something activates another (The active language).

Translation

A message is looked up in a DjangoTranslation, of which a process has one for each language it has used. It is made the first time the language is asked for, from every catalogue Django can find for it: Django’s own under django/conf/locale/, the locale directory of each installed application, and each directory of LOCALE_PATHS. They are layered so that LOCALE_PATHS come before the applications, an application listed earlier in INSTALLED_APPS before one listed later, and Django’s own beneath them all. Behind them stand the translations of LANGUAGE_CODE, which answer what the language’s own catalogues lack, except where the language is LANGUAGE_CODE itself or a code beginning "en"; a regional code such as "pt-br" has its language’s catalogue, "pt", before them (Message catalogues: DjangoTranslation).

django.utils.translation.gettext looks a message up in the active translation, or, with none active, in that of LANGUAGE_CODE, and gives the message back, its line endings made "\n", when no catalogue has it. django.utils.translation.pgettext adds a context, joined to the message by the character "\x04" as GNU gettext joins them, so that one English word can be translated two ways: in the office’s Dutch catalogue a berth that is free is “Vrij” and a service that is free “Gratis”. django.utils.translation.ngettext takes both forms of a message and a number, and the catalogue’s own formula, from its Plural-Forms header, chooses which of the translation’s forms the number takes (Translating a message).

django.utils.translation.activate makes a language’s translation the active one for the thread, and django.utils.translation.override does so for the length of a with block and then activates again the language that was active before, unless it was made with deactivate=True. Within Django, LocaleMiddleware activates the request’s language, {% language %} overrides it for a part of a template, and a command can be marked to run with translation off (The active language).

Code that runs at import, as a model’s or a form’s class body does, has no request’s language to translate into, and may run before the application registry has imported every application’s configuration, which making a DjangoTranslation requires. django.utils.translation.gettext_lazy returns instead an object that keeps the message and looks it up each time it is made a string, in the language active then (Lazy translations).

Choosing the language

LocaleMiddleware.process_request ordinarily activates the language that django.utils.translation.get_language_from_request returns, and puts its code on the request as LANGUAGE_CODE. The candidates, in order, are a language prefix in the path, where the root URLconf uses i18n_patterns; the cookie LANGUAGE_COOKIE_NAME names, "django_language" unless the setting says otherwise; the languages of the Accept-Language header, the most preferred first; and last LANGUAGE_CODE. A candidate is taken where LANGUAGES has it, or a more general code ("nl" for "nl-be"), and a catalogue exists for it; failing both, another regional variant of its language that LANGUAGES lists is taken in its place (Choosing a language: LocaleMiddleware). The prefix in the path, and the redirect process_response makes when a path lacks one, belong to Language prefixes and translated routes.

Two views serve the other end. set_language takes a posted language, sets the cookie, and redirects to the page "next" names, or else the one the visitor came from, translated into the new language where its address has one. JavaScriptCatalog sends the active language’s catalogue of the messages in JavaScript, the "djangojs" domain, with its plural formula and its formats, as a script that gives a browser its own gettext (The language views: set_language and JavaScriptCatalog).

Formats

A date or a number is written differently from one locale to the next, as much as a word is. get_format finds a format by its name, "DATETIME_FORMAT" or "DECIMAL_SEPARATOR", in the format modules of the active language: those under FORMAT_MODULE_PATH first, then Django’s own, django.conf.locale.nl.formats for Dutch, with a regional locale’s module tried before its language’s. A format that no module sets is the setting of the same name. Each answer is cached for the format and the language.

localize sends a value to the writer for its type: a number to number_format, which uses the locale’s decimal separator, and its thousands separator only where USE_THOUSAND_SEPARATOR is true; a datetime, a date or a time to date_format or time_format with the locale’s format of that name. A string comes back as it was, and a boolean as str writes it. A template localizes every value it writes, unless {% localize off %} says not to; a form’s date or time field reads what is typed in the locale’s input formats as well as the ISO ones (Locale formats: get_format and the format modules).

The writing itself is done by django.utils.numberformat.format, which groups the digits and places the separators, and by django.utils.dateformat, whose format strings are made of letters: "j F Y H:i" is the day, the month’s name, the year and the time. The names of the months and the days are lazy translations, so the one format string writes “maart” for Dutch and “März” for German (Writing numbers, dates and durations).

Time zones

With USE_TZ on, as it is by default, Django’s datetimes are aware: each carries a tzinfo and names an instant. One without is naive. django.utils.timezone.now gives the present in UTC, and a database backend that keeps no time zone stores an aware value in UTC, or in the zone the database’s settings name (Values in and out: adapters, converters and time zones).

The zone a datetime is shown in, and read in, is the current time zone: the one django.utils.timezone.activate installed for the thread, or else the default, TIME_ZONE. A project that shows each visitor their own time activates the zone itself, in a middleware of its own. On the way out a template ordinarily converts each aware datetime it writes to the current zone; on the way in a form takes a datetime typed without a zone as one in the current zone, and refuses a time that the zone’s clocks skip or repeat. In Python code, django.utils.timezone.localtime converts an aware value to the current zone, and make_aware attaches the current zone to a naive one, without the form’s refusal (Time zones).

Making catalogues

The catalogues are files of the GNU gettext format. A .po file is text, a message and its translation to an entry, the translations written by people; a .mo file is the same compiled to a table that the standard library’s module of the same name, gettext, reads. makemessages finds the messages a project’s Python files and templates mark for translation, turning each template first into something the GNU program xgettext can read, and writes or updates a .po file for each language; compilemessages runs msgfmt to make each .mo. Both need the GNU gettext programs installed (Making catalogues: makemessages and compilemessages).

Where internationalization meets the rest of Django

The language prefix of a path, translated routes and translate_url are in Language prefixes and translated routes. The template tags that translate, localize and convert time zones, {% translate %}, {% blocktranslate %}, {% language %}, {% localize %} and {% timezone %}, are in The libraries to load: static, i18n, l10n, tz and cache; the context processors that put the language and the time zone in a template’s context are in The context: Context, RequestContext and context processors; and the order in which a written value is converted, localized and escaped is in Rendering: nodes and the render context. A form field’s input formats and its use of the current time zone are in The built-in form fields, and a localized widget in Widgets: Widget and its subclasses. A model’s django.db.models.DateTimeField given a naive value is in The field classes, and how the backends store and read datetimes in Values in and out: adapters, converters and time zones. The date views and the current zone are in The date views. The settings object puts a settings module’s TIME_ZONE into the process’s environment where the platform allows (The settings object), and the caches of translations and formats emptied when a test overrides a setting are in Overriding settings. A command that must not translate is marked no_translations (Running a command: BaseCommand), and the autoreloader that watches .mo files is in The autoreloader. Lazy values themselves, lazy and Promise, belong to Lazy values: lazy, Promise and keep_lazy; the admin’s own JavaScript catalogue to The site’s own pages: the index, the login and AdminSite.each_context.