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 = ...).
(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"]
Source: Tornado: RequestHandler.locale
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 |
|---|---|
|
Loads |
|
Loads |
|
Declares the language the source strings are written in (default |
|
Closest supported |
|
The set of supported codes. |
|
Override to return a |
|
Parses |
|
The code (for example |
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.rtllets a layout switch todir="rtl"for Persian, Arabic and Hebrew. -
A
WebSocketHandleris aRequestHandler, soself.localeis also available there; the locale is derived from the handshake request’sAccept-Languageheader 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 |
|
|
Plural forms |
Exactly two ( |
Any number, from the catalog’s |
Message contexts ( |
Not supported (warning logged, context ignored) |
Supported |
Extraction and merge tooling |
None, you maintain the files by hand |
|
Build step |
None |
|
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 |
|---|---|
|
Takes a Unix timestamp or a |
|
|
|
|
|
Thousands separators for an integer, but only for the codes |
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"
Source: Tornado: Locale.format_date
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"
Source: Tornado: Locale.format_date
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 |
|
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 ( |
Region codes versus language codes |
A catalog |
Locale file naming |
CSV files must be named like |
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 |
|
It is attacker-controlled input, but |
Caching and |
A page that depends on |
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 |
|
Blocking work in |
It runs synchronously on the event loop. Do not query a database in it; load the preference in an async |
What Changed and What Is Upcoming
| Version | Change |
|---|---|
4.2 |
|
4.3 |
|
6.4 |
|
6.5.10 (current) |
No change to |
6.6 (alpha only, upcoming) |
No announced change to localization; the release raises the minimum Python version to 3.11. |
Source: Tornado: Release notes
Related Pages
-
Internationalization with Flask-Babel shows the same problem solved with Babel, including
pybabelextraction and lazy strings; Tornado has no lazy strings, because the locale is always resolved from the current handler. -
Django Internationalization & Translation covers a framework with built-in locale middleware and
.pocatalogs. -
Getting Started and Queues & Locks are earlier pages of this section.
-
Applications & Request Handlers covers
RequestHandlerandprepare(), Templates & UI Modules covers the template syntax and the_namespace in depth, and Authentication & Signed Cookies covers the signed-cookie login used for stored preferences.