Localization

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 Tornado 6.5.10 (released 2026-09-15) on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as the alpha 6.6a1 at that date, and nothing in its announced changes touches tornado.locale, so what follows is expected to carry over unchanged. Localization in Tornado is deliberately small: one module, tornado.locale, holds the translation catalogs and the Locale objects, and RequestHandler exposes the right Locale for the current request as self.locale and, in templates, as locale together with the shortcut _(). The module has no third-party dependency (the gettext support is the Python standard library’s gettext), which makes it the counterpart of Internationalization with Flask-Babel and of the Django page Internationalization & Translation, but with a much smaller surface. The running scenario is Bookshelf Live, whose pages and JSON errors are shown in English or Spanish (CSV catalog) and French or Russian (gettext catalogs).

How the Locale Is Chosen

Every RequestHandler has a lazily computed locale property. The first time it is read, the handler calls get_user_locale(); if that returns a Locale object it wins, and if it returns None (the default implementation) the handler falls back to get_browser_locale(), which parses the Accept-Language header. The result is cached on the handler for the rest of the request, and it can be assigned explicitly (self.locale = ...).

flowchart TD R["Request arrives"] --> P["handler.locale read
(first use)"] P --> U{"get_user_locale()
returns a Locale?"} U -- "yes" --> L["self.locale"] U -- "None" --> B["get_browser_locale():
Accept-Language sorted by q"] B --> G["tornado.locale.get(*codes)
closest supported code"] G -- "no match" --> D["default locale
(en_US unless set_default_locale)"] G -- "match" --> L D --> L L --> T["self.locale.translate()
_() and pgettext() in templates"]

The matching is done by tornado.locale.get(*locale_codes), which walks the codes in order and returns the closest supported one: a tight match (es_ES) or a loose one (es for a requested es-MX); codes are normalized so that en-gb, en_gb and en_GB are the same, and codes with more than two parts are skipped. If nothing matches it returns the default locale, en_US unless you call set_default_locale(). Supported means: the codes for which a catalog was loaded, plus the default locale. get_supported_locales() returns that set, which is what a language switcher should be validated against.

Function or method Behaviour

tornado.locale.load_translations(directory, encoding=None)

Loads LOCALE.csv files from a directory (see below). The encoding is detected from a byte-order mark (UTF-8 or UTF-16) and defaults to UTF-8. The encoding argument was added in Tornado 4.3.

tornado.locale.load_gettext_translations(directory, domain)

Loads directory/LANG/LC_MESSAGES/domain.mo for every language directory found.

tornado.locale.set_default_locale(code)

Declares the language the source strings are written in (default en_US). No catalog is needed for it; catalogs map the default language to the others.

tornado.locale.get(*codes), Locale.get_closest(*codes)

Closest supported Locale for the given codes, as described above.

tornado.locale.get_supported_locales()

The set of supported codes.

RequestHandler.get_user_locale()

Override to return a Locale from the authenticated user’s stored preference; return None to fall back to the browser.

RequestHandler.get_browser_locale(default="en_US")

Parses Accept-Language, orders the entries by their q value (entries with q=0 or a malformed q are dropped) and passes the codes to tornado.locale.get().

Locale.code, Locale.name, Locale.rtl

The code (for example es), the language’s own name when Tornado knows it (otherwise "Unknown"), and a flag that is true for the codes starting with fa, ar or he.

CSV Catalogs

The simplest format is one CSV file per language, named LOCALE.csv with a locale such as es or es_ES (a name that does not match the pattern lowercase letters plus an optional underscore and uppercase letters is logged as unrecognized and skipped). Each row has two or three columns: the source string, its translation, and an optional plural indicator, one of singular, plural or unknown (empty or missing means unknown). Strings use Python named placeholders such as %(count)d, because placeholders can move when a sentence is translated. Do not put spaces after the commas.

The code below writes a Spanish catalog for Bookshelf Live, loads it and shows tornado.locale.get negotiating between codes. The files would normally live in a translations/ directory next to the application; a temporary directory keeps the example self-contained.

import pathlib
import tempfile

import tornado.locale

BASE = pathlib.Path(tempfile.mkdtemp())
CSV_DIR = BASE / "csv"
CSV_DIR.mkdir()
(CSV_DIR / "es.csv").write_text(
    '"My shelf","Mi estantería"\n'
    '"Book not found","Libro no encontrado"\n'
    '"%(count)d book on your shelf","%(count)d libro en tu estantería","singular"\n'
    '"%(count)d books on your shelf","%(count)d libros en tu estantería","plural"\n',
    encoding="utf-8",
)

tornado.locale.load_translations(str(CSV_DIR))
print(sorted(tornado.locale.get_supported_locales()))  # ['en_US', 'es']

assert tornado.locale.get("es-MX").code == "es"          # loose match on the language
assert tornado.locale.get("fr", "es").code == "es"       # first supported code in order wins
assert tornado.locale.get("fr").code == "en_US"          # nothing matches: default locale
es = tornado.locale.get("es")
assert es.translate("My shelf") == "Mi estantería"
assert es.translate("Not in the catalog") == "Not in the catalog"   # untranslated text passes through
assert es.translate("%(count)d book on your shelf", "%(count)d books on your shelf", 1) % {"count": 1} \
    == "1 libro en tu estantería"
assert es.translate("%(count)d book on your shelf", "%(count)d books on your shelf", 5) % {"count": 5} \
    == "5 libros en tu estantería"

Locale.translate(message, plural_message=None, count=None) is the one translation primitive. With only a message it returns the translation, or the message itself if the catalog has none (so a forgotten string degrades to English instead of failing). With a plural message you must also pass count: the singular entry is used when count == 1 and the plural entry otherwise. The CSV format therefore knows exactly two plural forms, which is enough for English or Spanish but not for languages with more (see the gettext section). If no catalog was loaded at all, every translate() returns its input.

Translating in Handlers and Templates

In a handler, call self.locale.translate(...). In a template, Tornado puts four names in the namespace: locale (the Locale object), _ (its translate method), pgettext (its context-aware variant) and current_user, besides handler, request, static_url, xsrf_form_html and reverse_url. The _ call has the same two forms as translate. UI modules get the same object as self.locale.

The example serves a shelf page and a JSON endpoint. Pages go through a template loader that holds the templates in a dictionary (DictLoader, selected with the template_loader application setting; use template_path for real files), and the test client sends different Accept-Language headers.

import unittest

import tornado.web
from tornado.template import DictLoader
from tornado.testing import AsyncHTTPTestCase

TEMPLATES = {
    "shelf.html": (
        '<html lang="{{ locale.code.replace("_", "-") }}" dir="{{ "rtl" if locale.rtl else "ltr" }}">\n'
        '<h1>{{ _("My shelf") }}</h1>\n'
        '<p>{{ _("%(count)d book on your shelf", "%(count)d books on your shelf", count) % {"count": count} }}</p>\n'
        "</html>\n"
    )
}


class ShelfHandler(tornado.web.RequestHandler):
    def get(self):
        self.set_header("Content-Language", self.locale.code.replace("_", "-"))
        self.set_header("Vary", "Accept-Language")
        self.render("shelf.html", count=int(self.get_query_argument("count", "1")))


class BookHandler(tornado.web.RequestHandler):
    def get(self, book_id):
        # Strings returned from an API are translated with the same locale object.
        self.set_status(404)
        self.write({"message": self.locale.translate("Book not found")})


class ShelfPageTest(AsyncHTTPTestCase):
    def get_app(self):
        return tornado.web.Application(
            [(r"/shelf", ShelfHandler), (r"/books/(\d+)", BookHandler)],
            template_loader=DictLoader(TEMPLATES),
        )

    def test_spanish_from_accept_language(self):
        response = self.fetch("/shelf?count=3", headers={"Accept-Language": "es-ES,es;q=0.9,en;q=0.8"})
        body = response.body.decode()
        self.assertIn("<h1>Mi estantería</h1>", body)
        self.assertIn("3 libros en tu estantería", body)
        self.assertEqual(response.headers["Content-Language"], "es")
        self.assertEqual(response.headers["Vary"], "Accept-Language")

    def test_q_values_decide(self):
        response = self.fetch("/shelf", headers={"Accept-Language": "en;q=0.4, es;q=0.8"})
        self.assertIn("1 libro en tu estantería", response.body.decode())

    def test_default_when_unsupported_or_missing(self):
        for headers in ({"Accept-Language": "fr-FR"}, {}):
            body = self.fetch("/shelf?count=2", headers=headers).body.decode()
            self.assertIn("<h1>My shelf</h1>", body)
            self.assertIn("2 books on your shelf", body)

    def test_json_error_is_translated(self):
        response = self.fetch("/books/9", headers={"Accept-Language": "es"})
        self.assertEqual(response.code, 404)
        self.assertIn("Libro no encontrado", response.body.decode())


result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(ShelfPageTest))
assert result.wasSuccessful()

Notes on the template side:

  • The text produced by {{ _("...") }} is autoescaped like any other expression, translations included, so a translator cannot inject markup; if a translated string has to contain HTML, keep the markup in the template around the translated pieces rather than inside them. {% raw %} would switch escaping off and is not appropriate for translated text.

  • Prefer named placeholders (%(count)d) and apply the % operator to the translated text, as in the example, so a translator can reorder them.

  • locale.rtl lets a layout switch to dir="rtl" for Persian, Arabic and Hebrew.

  • A WebSocketHandler is a RequestHandler, so self.locale is also available there; the locale is derived from the handshake request’s Accept-Language header and stays fixed for the life of the connection.

Letting Users Choose: get_user_locale

The Accept-Language header describes the browser’s configuration, not the person’s wish. A common design is: an explicit choice (a stored preference, a cookie set by a language switcher, a query argument) wins, and the browser header is the fallback. Override get_user_locale() and return None to fall through.

Two details matter. First, get_user_locale() is a regular method, not a coroutine: if the preference lives in a database, load it in prepare() (which can be a coroutine) and assign self.locale there. Second, always check the requested code against get_supported_locales() before trusting a cookie or query value, and set the cookie only after that check.

import secrets
import unittest

import tornado.locale
import tornado.web
from tornado.testing import AsyncHTTPTestCase

# A stand-in for the real preferences table (an async database call in production).
PREFERENCES = {"ana": "es", "boris": "en_US"}


class BaseHandler(tornado.web.RequestHandler):
    def get_current_user(self):
        user = self.get_signed_cookie("user")
        return user.decode() if user else None

    def get_user_locale(self):
        # 1. A language chosen with the switcher, validated against the loaded catalogs.
        code = self.get_cookie("lang")
        if code and code in tornado.locale.get_supported_locales():
            return tornado.locale.get(code)
        # 2. None means: fall back to Accept-Language.
        return None


class PrefsHandler(BaseHandler):
    async def prepare(self):
        # Asynchronous lookup: do it here and assign the locale explicitly.
        user = self.current_user
        if user is not None:
            code = await self.load_preference(user)
            if code:
                self.locale = tornado.locale.get(code)

    async def load_preference(self, user):
        return PREFERENCES.get(user)

    def get(self):
        self.write({"title": self.locale.translate("My shelf"), "locale": self.locale.code})


class LoginHandler(BaseHandler):
    def get(self):
        # Demo only: a real login verifies a password first (see xref:backend/tornado/authentication-and-signed-cookies.adoc[Authentication & Signed Cookies]).
        self.set_signed_cookie("user", self.get_query_argument("name"))


class LanguageHandler(BaseHandler):
    def post(self):
        code = self.get_body_argument("lang")
        if code not in tornado.locale.get_supported_locales():
            raise tornado.web.HTTPError(400, reason="Unsupported language")
        self.set_cookie("lang", code, max_age=365 * 24 * 3600, samesite="Lax")
        self.set_status(204)


class UserLocaleTest(AsyncHTTPTestCase):
    def get_app(self):
        return tornado.web.Application(
            [(r"/me", PrefsHandler), (r"/login", LoginHandler), (r"/language", LanguageHandler)],
            cookie_secret=secrets.token_hex(32),   # random per run; load it from the environment in production
            xsrf_cookies=False,                    # keep the example short; see the XSRF page for real forms
        )

    def test_cookie_beats_header(self):
        response = self.fetch("/me", headers={"Cookie": "lang=es", "Accept-Language": "en"})
        self.assertEqual(response.body, b'{"title": "Mi estanter\\u00eda", "locale": "es"}')

    def test_unsupported_cookie_falls_back_to_header(self):
        response = self.fetch("/me", headers={"Cookie": "lang=xx", "Accept-Language": "es"})
        self.assertIn(b'"locale": "es"', response.body)

    def test_stored_preference_in_prepare(self):
        login = self.fetch("/login?name=ana")
        cookie = login.headers["Set-Cookie"].split(";")[0]
        response = self.fetch("/me", headers={"Cookie": cookie, "Accept-Language": "en"})
        self.assertIn(b'"locale": "es"', response.body)

    def test_language_switcher_validates(self):
        ok = self.fetch("/language", method="POST", body="lang=es")
        self.assertEqual(ok.code, 204)
        self.assertIn("lang=es", ok.headers["Set-Cookie"])
        bad = self.fetch("/language", method="POST", body="lang=klingon")
        self.assertEqual(bad.code, 400)


result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(UserLocaleTest))
assert result.wasSuccessful()

gettext Catalogs

The second format is the standard .mo binary produced by the GNU gettext tools. It is the right choice when you want translators to use common tools (Poedit, Weblate, Crowdin), when a language has more than two plural forms, or when you need message contexts. The catalogs live in a directory tree, directory/LANG/LC_MESSAGES/domain.mo, and are loaded with tornado.locale.load_gettext_translations(directory, domain). Under the hood each language is a standard library gettext.translation object, and the Locale class used for it, GettextLocale, calls its gettext and ngettext methods, so the plural rules come from the Plural-Forms header of the catalog.

The workflow has the same three steps that Tornado’s documentation lists: extract the strings, merge them into the per-language .po files, compile them to .mo. Tornado templates are not Python, but the xgettext Python parser is good enough to find _("...") and pgettext(...) calls in them, as long as the strings are plain literals:

# 1. Extract every translatable string (Python and templates) into a template catalog
xgettext --language=Python --from-code=UTF-8 \
    --keyword=_:1,2 --keyword=pgettext:1c,2 --keyword=pgettext:1c,2,3 \
    -d bookshelf -o translations/bookshelf.pot \
    app.py handlers/*.py templates/*.html

# 2. Start a new language, or merge new strings into an existing one
msginit --input=translations/bookshelf.pot --locale=fr --no-translator \
    -o translations/fr/LC_MESSAGES/bookshelf.po
msgmerge --update translations/fr/LC_MESSAGES/bookshelf.po translations/bookshelf.pot

# 3. Translate the .po file, then compile it (do this in CI or in the Docker build)
msgfmt translations/fr/LC_MESSAGES/bookshelf.po -o translations/fr/LC_MESSAGES/bookshelf.mo

The keyword specifications tell xgettext that _ takes the singular as argument 1 and the plural as argument 2, and that pgettext takes the context as argument 1 (the c suffix) and the message as argument 2, or 2 and 3 with a plural form. They are the ones given in the Tornado documentation for pgettext, which has been available on GettextLocale since Tornado 4.2 (it is a gettext-only feature: with CSV catalogs pgettext just logs a warning and returns the plain translation).

The next example writes a French and a Russian catalog, compiles them with msgfmt (from the GNU gettext package, which must be installed on the build machine; the compiled .mo files are what you ship), loads them and uses plural forms, message contexts and the three-form Russian plural that a CSV catalog cannot express.

import pathlib
import subprocess

import tornado.locale

GETTEXT_DIR = BASE / "locale"

HEADER = (
    'msgid ""\n'
    'msgstr ""\n'
    '"Content-Type: text/plain; charset=UTF-8\\n"\n'
    '"Plural-Forms: {plural}\\n"\n\n'
)

CATALOGS = {
    "fr": HEADER.format(plural="nplurals=2; plural=(n > 1);") + '''
msgid "My shelf"
msgstr "Mon étagère"

msgid "%(count)d book on your shelf"
msgid_plural "%(count)d books on your shelf"
msgstr[0] "%(count)d livre sur votre étagère"
msgstr[1] "%(count)d livres sur votre étagère"

msgctxt "verb"
msgid "Read"
msgstr "Lire"

msgctxt "past participle"
msgid "Read"
msgstr "Lu"

msgid "March"
msgstr "mars"

msgid "%(month_name)s %(day)s, %(year)s at %(time)s"
msgstr "%(day)s %(month_name)s %(year)s à %(time)s"

msgid "1 minute ago"
msgid_plural "%(minutes)d minutes ago"
msgstr[0] "il y a 1 minute"
msgstr[1] "il y a %(minutes)d minutes"

msgid "%(commas)s and %(last)s"
msgstr "%(commas)s et %(last)s"
''',
    "ru": HEADER.format(
        plural="nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"
    ) + '''
msgid "%(count)d book on your shelf"
msgid_plural "%(count)d books on your shelf"
msgstr[0] "%(count)d книга на вашей полке"
msgstr[1] "%(count)d книги на вашей полке"
msgstr[2] "%(count)d книг на вашей полке"
''',
}

for lang, po_text in CATALOGS.items():
    lc_messages = GETTEXT_DIR / lang / "LC_MESSAGES"
    lc_messages.mkdir(parents=True)
    (lc_messages / "bookshelf.po").write_text(po_text, encoding="utf-8")
    subprocess.run(
        ["msgfmt", str(lc_messages / "bookshelf.po"), "-o", str(lc_messages / "bookshelf.mo")],
        check=True,
    )

tornado.locale.load_gettext_translations(str(GETTEXT_DIR), "bookshelf")
print(sorted(tornado.locale.get_supported_locales()))   # ['en_US', 'fr', 'ru']

fr = tornado.locale.get("fr-CA")                          # loose match: fr
assert fr.code == "fr"
assert fr.translate("My shelf") == "Mon étagère"
one, many = "%(count)d book on your shelf", "%(count)d books on your shelf"
assert fr.translate(one, many, 1) % {"count": 1} == "1 livre sur votre étagère"
assert fr.translate(one, many, 4) % {"count": 4} == "4 livres sur votre étagère"
assert fr.pgettext("verb", "Read") == "Lire"                  # same English word, two translations
assert fr.pgettext("past participle", "Read") == "Lu"
assert fr.pgettext("unknown context", "Read") == "Read"       # not found: the message itself

ru = tornado.locale.get("ru")
for n, expected in [(1, "книга"), (3, "книги"), (5, "книг"), (11, "книг"), (21, "книга"), (22, "книги")]:
    assert expected in ru.translate(one, many, n) % {"count": n}, n

Compare the formats:

CSV (load_translations) gettext (load_gettext_translations)

Files

directory/LOCALE.csv, one per language

directory/LANG/LC_MESSAGES/domain.mo, compiled from .po

Plural forms

Exactly two (singular, plural rows)

Any number, from the catalog’s Plural-Forms header

Message contexts (pgettext)

Not supported (warning logged, context ignored)

Supported

Extraction and merge tooling

None, you maintain the files by hand

xgettext, msgmerge, msginit, translation platforms

Build step

None

msgfmt to compile .mo files

Typical use

Small apps, two or three languages

Anything that will grow or use professional translators

Both loaders replace the whole catalog set, and the process remembers that gettext was used: calling load_translations after load_gettext_translations leaves Tornado treating the CSV data as gettext objects and fails with an AttributeError the first time a locale is created. An application therefore uses one format; pick one.

Dates, Lists and Numbers

Locale also formats values, and it does so with the same translation machinery: the month names, weekday names and the format strings ("%(month_name)s %(day)s, %(year)s at %(time)s", "1 minute ago") are themselves messages in the catalog. A language therefore needs those strings translated for dates to appear in it; untranslated, they come out in English.

Method Behaviour

format_date(date, gmt_offset=0, relative=True, shorter=False, full_format=False)

Takes a Unix timestamp or a datetime. By default it returns a relative text for the last day ("3 minutes ago", "2 hours ago"), then "yesterday at 3:30 pm", a weekday, a month and day, and finally the full date. relative=False skips the "ago" forms, shorter=True drops the time of day, and full_format=True forces the full date. Future dates always use the full format. Naive datetimes are treated as UTC; aware datetimes are supported since Tornado 6.4.

format_day(date, gmt_offset=0, dow=True)

"Monday, January 22", or "January 22" with dow=False.

list(parts)

"A, B and C", "A and B" or "A", using the translated "%(commas)s and %(last)s".

friendly_number(value)

Thousands separators for an integer, but only for the codes en and en_US; every other locale gets the plain str(value).

gmt_offset is a number of minutes with the sign of JavaScript’s getTimezoneOffset(): the local time is the UTC time minus the offset, so Madrid in winter (UTC+1) is -60. format_date does not know about time zones by name, so compute the offset yourself with zoneinfo from the user’s zone (stored preference, or sent by the browser) and pass it in. The 12-hour clock with am/pm is hard-wired for en, en_US and the Chinese zh_CN form; every other code uses a 24-hour H:MM time.

The French catalog from the previous section already contains the date strings used below (March, the full-date format, 1 minute ago with its plural, and the list conjunction), for example:

msgid "%(month_name)s %(day)s, %(year)s at %(time)s"
msgstr "%(day)s %(month_name)s %(year)s à %(time)s"

msgid "1 minute ago"
msgid_plural "%(minutes)d minutes ago"
msgstr[0] "il y a 1 minute"
msgstr[1] "il y a %(minutes)d minutes"

The next example formats the same instants in English and French.

import datetime
from zoneinfo import ZoneInfo

import tornado.locale

fr = tornado.locale.get("fr")
en = tornado.locale.get("en_US")
published = datetime.datetime(2024, 3, 5, 14, 30, tzinfo=datetime.timezone.utc)

# Minutes to subtract from UTC for the reader's zone (JavaScript getTimezoneOffset() sign).
madrid = ZoneInfo("Europe/Madrid")
gmt_offset = -int(published.astimezone(madrid).utcoffset().total_seconds() // 60)
assert gmt_offset == -60

assert en.format_date(published, relative=False) == "March 5, 2024 at 2:30 pm"
assert en.format_date(published, gmt_offset=gmt_offset, relative=False) == "March 5, 2024 at 3:30 pm"
assert fr.format_date(published, gmt_offset=gmt_offset, relative=False) == "5 mars 2024 à 15:30"

three_minutes_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=3)
assert en.format_date(three_minutes_ago) == "3 minutes ago"
assert fr.format_date(three_minutes_ago) == "il y a 3 minutes"

assert en.format_day(published, dow=False) == "March 5"
assert en.list(["Dune", "Emma", "Ulysses"]) == "Dune, Emma and Ulysses"
assert fr.list(["Dune", "Emma", "Ulysses"]) == "Dune, Emma et Ulysses"
assert en.friendly_number(1234567) == "1,234,567" and fr.friendly_number(1234567) == "1234567"

For anything beyond these helpers (currency, locale-specific number grouping, calendar systems, CLDR plural rules for numbers) Tornado offers nothing; use the Babel library’s babel.dates, babel.numbers and babel.support modules alongside, as the Flask page does. Keep the two systems separate: strings through tornado.locale, formatted values through Babel, or switch the whole application to Babel’s gettext support.

Loading Catalogs at Startup

The catalogs are module-level state of tornado.locale, not a setting of an Application. Load them once, before the server starts accepting requests, and set the default locale first so the supported set includes it:

# norun
import asyncio
import pathlib

import tornado.locale
import tornado.web

TRANSLATIONS = pathlib.Path(__file__).parent / "translations"


async def main():
    tornado.locale.set_default_locale("en_US")
    tornado.locale.load_gettext_translations(str(TRANSLATIONS), "bookshelf")
    app = tornado.web.Application([...], template_path=str(TRANSLATIONS.parent / "templates"))
    app.listen(8888)
    await asyncio.Event().wait()


if __name__ == "__main__":
    asyncio.run(main())

set_default_locale("es") is for an application whose source strings are Spanish: the default language is the one the untranslated text is written in, and it never needs a catalog. A missing catalog directory is not an error: load_translations raises FileNotFoundError for a directory that does not exist, while an empty or incomplete load_gettext_translations tree simply yields an empty set of locales, so a deployment that forgets to ship the .mo files runs in English without a warning. Check get_supported_locales() in a startup test.

Pitfalls

Pitfall Detail and remedy

Catalogs cannot be hot-reloaded

Locale objects are cached per code. Reloading translations in a running process updates the catalog dictionaries but tornado.locale.get() keeps returning the previously created Locale for codes it has already served, so edited CSV files or recompiled .mo files are not seen until the process restarts. Debug mode’s autoreload watches Python modules, not catalog files; restart the server (or add the catalog files to the autoreloader’s watch list with tornado.autoreload.watch) while translating.

Global, process-wide state

Every handler and every test in the process sees the same catalogs. Load them in one place at startup; in tests, load them once per test module (setUpModule or a base class) and use distinct locale codes if two test suites need different catalogs.

Region codes versus language codes

A catalog es_ES.csv does not serve es-MX; the loose match is on the bare language only (es). Name a catalog for the language (es) unless regional wording really differs, and add es_MX only on top of it.

Locale file naming

CSV files must be named like es.csv or es_ES.csv; anything else is logged as "Unrecognized locale" and ignored. gettext directories use the same codes.

Two plural forms in CSV

Russian, Polish, Arabic and other languages need more forms. Use gettext for them; the CSV format silently picks the singular row for count == 1 and the plural row for everything else, which is wrong for those languages.

Accept-Language is a hint

It is attacker-controlled input, but tornado.locale.get only ever returns a supported locale, so it cannot select an arbitrary file. Do the same for cookies and query arguments: validate against get_supported_locales(). Do not build paths from a raw locale string.

Caching and Vary

A page that depends on Accept-Language must be sent with Vary: Accept-Language, or a shared cache or CDN will serve one user’s language to another. Setting Content-Language helps clients and crawlers. Pages whose language comes from a cookie are not cacheable by URL alone.

Concatenated sentences

Translate whole sentences with named placeholders, never fragments joined in code. Word order, gender and plural agreement differ between languages.

Translating at import time

_() needs the request’s locale. A module-level constant like TITLE = locale.translate("My shelf") is evaluated once, in no locale; keep untranslated constants and translate when rendering.

Blocking work in get_user_locale

It runs synchronously on the event loop. Do not query a database in it; load the preference in an async prepare() and assign self.locale, as shown above.

What Changed and What Is Upcoming

Version Change

4.2

pgettext support for gettext catalogs.

4.3

load_translations accepts an encoding and detects UTF-8 and UTF-16 byte-order marks.

6.4

Locale.format_date accepts timezone-aware datetime objects.

6.5.10 (current)

No change to tornado.locale.

6.6 (alpha only, upcoming)

No announced change to localization; the release raises the minimum Python version to 3.11.