Skip to content

5. Internationalization

An internationalized application displays its text in the user’s language. The case study is in French and English. The principle is simple: the templates contain text marked as “to be translated,” and a dictionary for each language associates each text with its translation. The selected language is stored in a cookie.

[Django] uses [gettext] for this purpose, the translation system from the GNU project, which [Python] also recognizes (the [gettext] module from the standard library), and which is found in many open-source software programs. With [gettext], the key for a text is the text itself, written in the original language: a template written in [{% translate "Home" %}], and the French dictionary associates “Home” with “Accueil.” A page thus remains readable and functional even without a dictionary: the text is displayed in the original language. This differs from versions [Symfony], [Laravel], [Flask], [Spring MVC], [ASP.NET Core MVC], and [NestJS] from the case study, which used abstract keys ([MENU.HOME]). Dictionaries are text files in the [.po] (Portable Object) format, which tools can extract from the code and which translation software can edit; [Django] reads their compiled version, the [.mo] (Machine Object) files. [gettext] can also inflect text into the plural form according to the rules of each language. [Django] is itself translated in this way: it is thanks to its dictionaries that it can display its error messages in French (since Example 08, they have been in English, the default language). The formatting of dates and amounts remains the same as in Example 16.

5.1. Example 22: A Bilingual Application

22-i18n/
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── locale/
│   └── fr/
│       └── LC_MESSAGES/
│           ├── django.mo
│           └── django.po
├── pages/
│   ├── templatetags/
│   │   ├── __init__.py
│   │   └── formats.py
│   ├── __init__.py
│   ├── viewmodels.py
│   └── views.py
├── static/
│   └── ...
├── templates/
│   ├── fragments/
│   │   └── navbar.html
│   ├── pages/
│   │   ├── home.html
│   │   └── products.html
│   └── layout.html
└── manage.py

5.1.1. The configuration: config/settings.py

# config/settings.py: the configuration of example 22

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-e%uqntr!nz5i2w(d^_-cwr6w=w^2z^mx6vpvpgsg91qvp)bnzl"
ALLOWED_HOSTS = []
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

# the default language, and the languages handled by the application
LANGUAGE_CODE = "en"
LANGUAGES = [
    ("fr", "Français"),
    ("en", "English"),
]
# the folders of the application's DICTIONARIES (.po files, compiled into .mo)
LOCALE_PATHS = [BASE_DIR / "locale"]
# the cookie that remembers the chosen language (it is also the default name)
LANGUAGE_COOKIE_NAME = "django_language"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "pages",
]

MIDDLEWARE = [
    # LocaleMiddleware: the language of each request, read from the language cookie,
    # failing that from the browser's Accept-Language header, failing that LANGUAGE_CODE
    "django.middleware.locale.LocaleMiddleware",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                # the LANGUAGE_CODE variable: the language of the request
                "django.template.context_processors.i18n",
            ],
        },
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
    BASE_DIR.parent / "common" / "static",
]
  • Line 14: the application’s default language, which is used for requests that do not specify a language (Example 08). This is also the language of the template texts; therefore, English does not require a dictionary;
  • Lines 15–18: the languages supported by the application;
  • Line 20: [LOCALE_PATHS], the folders where [Django] searches for the application's dictionaries, in addition to the [locale] folders for each application (including those for [Django] itself). Each dictionary is stored in a [<language>/LC_MESSAGES] folder, under the name [django.po] (and [django.mo]): this is the organization required by [gettext];
  • line 22: the name of the cookie that stores the selected language. [django_language] is the default value; we include it here so it is visible;
  • lines 29–33: the [LocaleMiddleware] middleware from [Django] replaces our [SetLanguage] middleware from Example 16. For each request, it selects the language from among those specified in the [LANGUAGES] setting: the language specified in the language cookie, if one exists; otherwise, the browser’s preferred language, based on the [Accept-Language] header (Example 12); otherwise, [LANGUAGE_CODE]. It activates it ([translation.activate], Example 16), and stores it in [request.LANGUAGE_CODE]. Finally, it adds the header [Content-Language] (the page’s language) to the response, along with the header [Vary: Accept-Language], which notifies caches that the response depends on this header;
  • line 43: the context processor [i18n] adds the variables [LANGUAGE_CODE] (the language of the request), [LANGUAGES], and [LANGUAGE_BIDI] (true for a language written from right to left) to the templates.

5.1.2. The dictionary: locale/fr/LC_MESSAGES/django.po

# locale/fr/LC_MESSAGES/django.po: the French dictionary of example 22.
# msgid: the original text, as it is written in the templates (in English);
# msgstr: its translation. This file is compiled into django.mo (the file that
# Django reads) by the command: python manage.py compilemessages
msgid ""
msgstr ""
"Language: fr\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"

#: templates/fragments/navbar.html:6
msgid "Home"
msgstr "Accueil"

#: templates/fragments/navbar.html:7
msgid "Products"
msgstr "Produits"

#: templates/pages/home.html:4 templates/pages/home.html:7
msgid "Welcome"
msgstr "Bienvenue"

#: templates/pages/home.html:9
#, python-format
msgid "Hello %(name)s!"
msgstr "Bonjour %(name)s !"

#: templates/pages/home.html:11
#, python-format
msgid "Today is %(date)s."
msgstr "Nous sommes le %(date)s."

#: templates/pages/products.html:4 templates/pages/products.html:7
msgid "Our products"
msgstr "Nos produits"

#: templates/pages/products.html:9
msgid "Product"
msgstr "Produit"

#: templates/pages/products.html:9
msgid "Price"
msgstr "Prix"

#: templates/pages/products.html:21
#, python-format
msgid "%(n)s product in the catalogue."
msgid_plural "%(n)s products in the catalogue."
msgstr[0] "%(n)s produit au catalogue."
msgstr[1] "%(n)s produits au catalogue."

A [.po] file is a sequence of entries, separated by blank lines:

  • lines 1–4: a comment;
  • lines 5–11: the header entry: an empty string ([msgid ""]), whose “translation” ([msgstr]) contains the dictionary information: the language, the character set, and the language’s plural rule ([Plural-Forms]: in French, two forms, the second for any number greater than 1);
  • lines 13–15: a standard entry: [msgid] is the source text, as written in the template; [msgstr] is its translation. The line [#:] indicates where the text was found (a template and a line): this is a comment for the translator;
  • lines 25–28: text containing a variable. In the dictionary, a variable is written as [%(name)s] (the syntax for format strings from [Python], example 10); the line [#, python-format] flags this to the tools. The translation may reposition the variable or change the punctuation surrounding it (in French, the exclamation point is preceded by a space);
  • lines 47–52: a plural form: [msgid] is the singular form, [msgid_plural] is the plural form, and each form in the translation is numbered ([msgstr[0]], [msgstr[1]]), in the order specified by the rule [Plural-Forms]. In French, 0 and 1 are singular, whereas in English 0 is plural (“0 products”): [gettext] knows this, thanks to this rule. This is an improvement over the “product(s)” used in previous versions of the case study.

This file is not written by hand. The command:

python manage.py makemessages -l fr

scans the project’s templates and the [Python] code, extracts all text marked “to be translated,” and creates or updates the [locale/fr/LC_MESSAGES/django.po] file: new text is added to it with an empty translation, and existing translations are preserved. All that remains is to write the translations, either in a text editor or in specialized software ([Poedit], for example). The command:

python manage.py compilemessages

It then compiles each [.po] file into a [.mo] file—a binary file that is quick to read—which is the one used by [Django]. You must restart the server after a compilation: the dictionaries are loaded once and for all. These two commands use the programs from [gettext] ([xgettext], [msgfmt]...), which must be installed (for [Linux], the [gettext] package; for [Windows], for example, the binaries distributed on the mlocati.github.io website). Since the [django.mo] file is provided with the example, it is not necessary to install these packages to run it.

5.1.3. Language selection: routes and views

# config/urls.py: the main routing table

from django.urls import path, re_path

from pages import views

urlpatterns = [
    path("", views.home, name="home"),
    path("products", views.products, name="products"),
    # re_path: a route described by a regular expression; a language other
    # than fr or en does not match the route (404)
    re_path(r"^language/(?P<code>fr|en)$", views.change_language, name="language"),
]
  • Line 12: [re_path] describes a route using a regular expression, rather than the syntax of [path] (Example 02). [^] and [$] mark the start and end of the path; [(?P<code>fr|en)] is a named group: the text it recognizes—[fr] or [en] (the vertical bar means “or”)—is passed to the view in the [code] parameter. Another language does not match any route: [/language/de] returns a 404 error.
# pages/views.py: the views know nothing about the language: they provide
# data, the templates translate and format them

from datetime import date

from django.conf import settings
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_GET

from .viewmodels import Product


@require_GET
def home(request):
    return render(request, "pages/home.html", {"name": "Marie", "today": date(2026, 9, 23)})


@require_GET
def products(request):
    return render(request, "pages/products.html", {"products": [
        Product("Keyboard", 2500),
        Product("Mouse", 1250),
        Product("Screen", 114900),
    ]})


# GET /language/fr: remembers the choice in the language cookie, then goes back to the
# previous page
@require_GET
def change_language(request, code):
    response = redirect(origin_page(request))
    # the cookie: one year of life, unreadable by the page's JavaScript (httponly),
    # never sent with a POST request coming from another site (samesite Lax)
    response.set_cookie(settings.LANGUAGE_COOKIE_NAME, code, max_age=365 * 24 * 3600,
                        httponly=True, samesite="Lax")
    return response


# the page we come from (Referer header), if it belongs to THIS site: redirecting
# to any address coming from the request would make it possible to send
# the user to another site ("open redirect" flaw)
def origin_page(request):
    referer = request.headers.get("Referer", "")
    # url_has_allowed_host_and_scheme: true for a relative address, or an absolute
    # address whose host is that of the request
    if url_has_allowed_host_and_scheme(referer, allowed_hosts={request.get_host()}):
        return referer
    return "/"
  • Lines 14–25: The home and product views do not know the language; they provide raw data (a name, a date, amounts in cents). Translation and formatting are handled by the templates. The class [Product] has a name and a price in cents; the library [formats] is the one from Example 16: its filter [long_date] writes a date in the “full” format of the active language (“Wednesday, September 23, 2026,” “mercredi 23 septembre 2026”), and its filter [money] displays an amount;
  • lines 30–37: the language-change view responds by redirecting the user back to the page they came from; the user is returned to their original page in the new language;
  • lines 35–36: The response includes a cookie, which the browser will send back with every request. [set_cookie] receives its name, value, and options: [max_age], its lifetime in seconds (one year); [httponly=True] makes it invisible to the [JavaScript] on the page; [samesite="Lax"] prevents the browser from sending it with a POST request originating from another site. It is added to the response as a [Set-Cookie] header;
  • lines 43–49: the original page is specified by the [Referer] header in the request (the misspelled version, with a single [r], is that of the HTTP standard), which the browser populates with the URL from the page that contained the link. But this header comes from the client, which can put anything in it. Blindly redirecting to this address would turn our application into an open redirect: a malicious link bearing our domain name could send the user to a malicious site. The function [url_has_allowed_host_and_scheme] in [Django] ([django.utils.http]) checks whether an address is relative or whether it points to one of the allowed machines (in this case, the one from the request: [request.get_host()]); otherwise, the user is redirected to the home page.

[Django] also provides a ready-made view for changing the language, [django.views.i18n.set_language], which performs the same task using a form (POST); we wrote our own to demonstrate it and to allow language switching via a simple link.

5.1.4. Translating in Templates

<!doctype html>
{% load static %}
{# LANGUAGE_CODE: the language of the request, "fr" or "en" #}
<html lang="{{ LANGUAGE_CODE }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# the title: the [title] block of the page, already translated #}
  <title>{% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
  <link rel="stylesheet" href="{% static 'css/styles.css' %}">
</head>
<body>
  <div class="container py-3">
    {% include "fragments/navbar.html" %}
    <main>
      {% block content %}{% endblock %}
    </main>
  </div>
</body>
</html>
  • line 4: the page’s [lang] attribute indicates its language to browsers, search engines, and screen readers; [LANGUAGE_CODE] is the language of the request, provided by the context processor [i18n];
  • Line 9: The page title is provided by the page itself, already translated (block [title]).
{# i18n: the library of the translation tags #}
{% load i18n %}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <div class="navbar-nav me-auto">
    {# translate: the translation of a text, in the language of the request #}
    <a class="nav-link" href="{% url 'home' %}">{% translate "Home" %}</a>
    <a class="nav-link" href="{% url 'products' %}">{% translate "Products" %}</a>
  </div>
  {# get_available_languages: the languages of the LANGUAGES setting, pairs (code, name) #}
  {% get_available_languages as languages %}
  <div class="btn-group btn-group-sm">
    {% for code, name in languages %}
      {# the current language is highlighted #}
      <a class="btn {% if code == LANGUAGE_CODE %}btn-primary{% else %}btn-outline-primary{% endif %}"
         href="{% url 'language' code %}" title="{{ name }}">{{ code|upper }}</a>
    {% endfor %}
  </div>
</nav>
  • line 2: the translation tags are in the [i18n] library, which each template must load (example 16);
  • lines 6–7: The [{% translate %}] tag returns the translation of a text into the language of the request (or the text itself, if it has no translation);
  • lines 10–17: The [{% get_available_languages %}] tag stores the languages from the [LANGUAGES] setting—pairs of (code, name)—in a variable, which the loop then parses ([for code, name in languages]). One button per language, which links to the [/language/<code>] view. The current language is highlighted ([btn-primary], solid button).
{% extends "layout.html" %}
{% load i18n formats %}

{% block title %}{% translate "Welcome" %}{% endblock %}

{% block content %}
  <h1>{% translate "Welcome" %}</h1>
  {# blocktranslate: a text that contains {{ name }} variables #}
  <p>{% blocktranslate %}Hello {{ name }}!{% endblocktranslate %}</p>
  {# with: the date, formatted first (long_date filter, see example 16), then inserted in the text #}
  <p>{% blocktranslate with date=today|long_date %}Today is {{ date }}.{% endblocktranslate %}</p>
{% endblock %}
  • line 4: the [title] block, translated, for the parent template;
  • line 9: the [{% blocktranslate %}] tag translates text containing variables: [{{ name }}] becomes [%(name)s] in the dictionary. Only simple variables are allowed here (no filters or periods);
  • line 11: a calculated variable can therefore only be included in the text via [with]: the date, formatted in the query language by the filter [long_date], is stored in the variable [date] in the text.
{% extends "layout.html" %}
{% load i18n formats %}

{% block title %}{% translate "Our products" %}{% endblock %}

{% block content %}
  <h1>{% translate "Our products" %}</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>{% translate "Product" %}</th><th class="text-end">{% translate "Price" %}</th></tr></thead>
    <tbody>
      {% for product in products %}
        <tr>
          <td>{{ product.name }}</td>
          {# the money filter of example 16: the format depends on the language #}
          <td class="text-end">{{ product.price_cents|money }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  {# the plural: count chooses the form according to the number n; plural separates the forms #}
  <p>{% blocktranslate count n=products|length %}{{ n }} product in the catalogue.{% plural %}{{ n }} products in the catalogue.{% endblocktranslate %}</p>
{% endblock %}
  • line 15: the filter [money], whose result depends on the language;
  • line 21: the plural. [count n=products|length] returns the number that determines the form ([length], example 14); the [{% plural %}] tag separates the singular form from the plural form, written in the source language. Product names, however, are not translated: they are data that, in a real-world application, would come from a database.

Outside of templates, translation is handled by the functions of the [django.utils.translation] module: [gettext("Home")] (often imported under the short name [_]: [_("Home")]), [ngettext] for the plural, and [gettext_lazy], a “lazy” translation performed only when the text is used: this is the one to use for text defined when a module is loaded (the label of a form field, for example), when the language of the request is not yet known. The case study uses it.

5.1.5. Execution

The home page, in English (the default language), then in French after clicking the “FR” button:

Image

Image

The products page, in both languages. The amounts follow the conventions of each language:

Image

Image

The language switch, as seen with [curl]. The option [-H] provides the header [Referer] that the browser would send:

curl -i -H "Referer: http://localhost:8000/products" http://localhost:8000/language/fr
1
2
3
4
5
6
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: http://localhost:8000/products
Vary: Accept-Language
Content-Language: en
Set-Cookie: django_language=fr; expires=Mon, 27 Sep 2027 17:37:43 GMT; HttpOnly; Max-Age=31536000; Path=/; SameSite=Lax

The redirect takes you back to the original page, and the cookie is set to last for one year. The response itself is still in English ([Content-Language: en]): the language was only selected when the request arrived, before the cookie existed. A [Referer] that points to another site does not allow you to be redirected there:

curl -i -H "Referer: https://pirate.example/trap" http://localhost:8000/language/fr
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /
...

The user returns to the home page and remains on our site. Finally, the home page, requested with the cookie [django_language=fr] (option [-b] of [curl]):

curl -b "django_language=fr" http://localhost:8000/
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Bienvenue</title>
...
    <a class="nav-link" href="/">Accueil</a>
    <a class="nav-link" href="/products">Produits</a>
...
  <h1>Bienvenue</h1>
  <p>Bonjour Marie !</p>
  <p>Nous sommes le mercredi 23 septembre 2026.</p>
...

The cookie is not signed: it is transmitted in plain text, and the user can modify it. This is not a problem: it contains no confidential information, and [LocaleMiddleware] only accepts the languages specified in the [LANGUAGES] setting. The following example illustrates the level of trust that can be placed in a cookie. Without a cookie, [LocaleMiddleware] reads the browser’s [Accept-Language] header: [curl -H "Accept-Language: fr-FR,fr;q=0.9" http://localhost:8000/] renders the page in French. The two mechanisms thus combine naturally: the browser’s language on the first visit, followed by the language the user has chosen. A third, common approach places the language in the URL ([/fr/products]): the [i18n_patterns] function of [Django] then prefixes the routes with the language, and [LocaleMiddleware] reads it from the path. This case study focuses on the cookie.