Internationalization

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 Pyramid 2.1 (released 2026-03-11), translationstring 1.4, Lingua 4.16.2, Babel 2.18.0, pyramid_jinja2 2.10.1 and pyramid_chameleon 0.3, checked on 2026-10-07. Pyramid 2.1 supports Python 3.10 to 3.14; the examples were executed on Python 3.13 with WebTest 3.0.7 and WebOb 1.8.11. The running scenario is Bookshelf Shelves: public shelves owned by users, each holding books. This page makes the shelf pages speak more than one language.

Internationalization (i18n) is writing software so that its user interface can be shown in several languages; localization (l10n) is showing it in one particular language or cultural convention. Pyramid’s part of the work is small and deliberate. It defines a translation string, a lazy str that remembers its message id, domain, default text and interpolation values; it reads gettext .mo catalogs from the directories you register; it decides which locale a request wants through a replaceable locale negotiator; and it hands you a Localizer that translates and pluralizes for that locale. Everything else is delegated: extracting messages is done by Lingua, Babel or GNU gettext; dates, numbers and currencies are formatted by Babel; Chameleon translates translation strings on its own, and Jinja2 reaches the localizer through pyramid_jinja2. By default a Pyramid application performs no translation: it starts translating only once you register at least one translation directory and the locale is set correctly.

The same job is done elsewhere on this site by Flask-Babel (Internationalization with Flask-Babel), by tornado.locale (Localization) and by Django’s built-in machinery (Internationalization and Translation). The gettext catalog formats are shared by all of them, which is why a translator’s .po files move easily between frameworks.

From Marked String to Translated Page

A translation travels through a fixed pipeline. At build time, strings in code and templates are marked, extracted into a .pot template, merged into one .po file per locale that translators edit, and compiled into binary .mo files. At startup the application registers the directory that contains the compiled catalogs. On each request the locale negotiator picks a locale name, Pyramid builds (and caches) a Localizer for it from the registered catalogs, and your code or template asks the localizer for a translation.

The steps from marking strings in code, through the pot, po and mo files and add_translation_dirs, to the negotiated locale, the cached localizer and the translated text
Figure 1. The life of a translation in a Pyramid application

Three libraries do the work underneath. The translationstring package (a dependency of Pyramid) supplies TranslationString, TranslationStringFactory, the Translator and the Pluralizer that Pyramid re-exports from pyramid.i18n. The standard library’s gettext.GNUTranslations reads the .mo files; Pyramid wraps it in its own Translations class (the code was adapted from Babel) so that several catalogs, one per domain, can be merged. The catalog files themselves are produced by external tools, and Pyramid ignores .po files completely: if you edit a .po file and forget to compile it, nothing changes.

Translation Strings

A translation string is an object that behaves like a str but carries the metadata the translation machinery needs. The most primitive way to create one is pyramid.i18n.TranslationString; the idiomatic way is a TranslationStringFactory, which presets the domain and is conventionally bound to the name _, the name that extraction tools look for.

Table 1. Translation string attributes
Part Meaning

msgid

The first argument: the key looked up in the catalog. It is also what str() returns and what the string compares equal to, which is the source of the pitfalls below.

default

Text used when no translation is found. When it is None, the msgid doubles as the default text. Setting a default lets you use short, opaque message ids such as shelf-title.

mapping

Values for replacement markers written ${name} in the msgid or default. Markers without a value in the mapping are left in the output literally. This is not Python str.format and not %(name)s syntax.

domain

The translation category, which selects the catalog file: domain bookshelf is read from bookshelf.mo. A factory presets it.

from pyramid.i18n import TranslationString, TranslationStringFactory

_ = TranslationStringFactory("bookshelf")

title = _("shelf-title", default="Shelf: ${name}", mapping={"name": "Fiction"})
plain = _("Add a book")

print(type(title).__name__, title.domain, title.mapping)
print(repr(str(title)), title == "shelf-title")
print(title.interpolate(), "|", plain.interpolate())
TranslationString bookshelf {'name': 'Fiction'}
'shelf-title' True
Shelf: Fiction | Add a book

Giving every package its own domain, through its own factory, is the documented best practice: another application can then reuse your package and simply include your translation directory without merging its catalogs with its own. The convention for a project named bookshelf_shelves is a single module that defines the factory once, for example _ = TranslationStringFactory("bookshelf"), and imports it everywhere.

A translation string is lazy. Creating it costs nothing and requires no request, so it is safe in module-level constants, class attributes, form definitions or schema nodes, where no locale exists yet. It is translated only when something passes it to a localizer or to a renderer that knows how. Until then str(title) is the untranslated msgid (shelf-title above), and that is the first pitfall: json.dumps, an f-string or a Jinja2 {{ title }} prints the message id. A renderer that is not aware of translation strings (the built-in JSON renderer is one) needs the string translated by request.localizer.translate(...) first.

Message Catalogs: pot, po and mo

GNU gettext uses three file types. A .pot (portable object template) lists every message id found in the source and is regenerated at will. A .po file (portable object) is a copy of the template with a translation per message for one locale; humans edit it, with a text editor or a tool such as Poedit, and it is committed to version control. A .mo (machine object) file is the compiled, binary form that Pyramid reads. Catalogs live in a translation directory with one folder per locale and a fixed LC_MESSAGES subfolder, one file per domain:

bookshelf_shelves/locale/
    bookshelf.pot                       template, regenerated
    es/LC_MESSAGES/bookshelf.po         translated by humans
    es/LC_MESSAGES/bookshelf.mo         compiled, read by Pyramid
    de/LC_MESSAGES/bookshelf.po
    de/LC_MESSAGES/bookshelf.mo

Two toolchains produce these files, and the official Pyramid chapter documents the first. Both extract the same marks (_("..."), _("...", default="...")), so choose by what else you need to extract.

Table 2. Catalog toolchains
Step Lingua and GNU gettext (the Pyramid chapter) Babel (pybabel)

Install

pip install lingua, plus the GNU gettext package from your OS for msginit, msgmerge and msgfmt

pip install Babel; pure Python, no OS package needed

Extract

pot-create; Python and Chameleon (.pt) templates; Mako through its own plugin

pybabel extract with a mapping file; Python, Jinja2 and Mako (a .jinja2 file has no extractor in pot-create)

Create a locale

msginit (also writes the Plural-Forms header)

pybabel init

Update after changes

msgmerge --update

pybabel update

Compile

msgfmt

pybabel compile

With Lingua and GNU gettext the whole cycle for the shelves project is:

pip install lingua            # plus the OS package "gettext" for msginit, msgmerge and msgfmt

# 1. extract every _() mark into a template
mkdir -p bookshelf_shelves/locale
pot-create -o bookshelf_shelves/locale/bookshelf.pot bookshelf_shelves

# 2. create the Spanish catalog the first time
cd bookshelf_shelves/locale
mkdir -p es/LC_MESSAGES
msginit --no-translator -l es -i bookshelf.pot -o es/LC_MESSAGES/bookshelf.po

# 3. later, after re-running pot-create, merge new and changed messages
msgmerge --update --backup=none es/LC_MESSAGES/bookshelf.po bookshelf.pot

# 4. translate es/LC_MESSAGES/bookshelf.po, then compile it
msgfmt --check -o es/LC_MESSAGES/bookshelf.mo es/LC_MESSAGES/bookshelf.po

pot-create also writes the default text of a message that has one into a #. Default: comment above the entry, so the translator sees Shelf: ${name} next to the opaque id shelf-title. Without --backup=none, msgmerge --update leaves a bookshelf.po~ backup next to the catalog, which you do not want committed. Plural calls are not extracted by default because Lingua does not know the name pluralize; add it with -k pluralize:1,2 (singular is argument 1, plural argument 2).

The Babel route needs a small mapping file, babel.cfg, that assigns an extractor to each glob, because it is the one that can read Jinja2 templates:

[python: bookshelf_shelves/**.py]
[jinja2: bookshelf_shelves/templates/**.jinja2]
pip install Babel
mkdir -p bookshelf_shelves/locale          # pybabel extract does not create the folder
pybabel extract -F babel.cfg -k pluralize:1,2 -o bookshelf_shelves/locale/bookshelf.pot .
pybabel init    -i bookshelf_shelves/locale/bookshelf.pot -d bookshelf_shelves/locale -D bookshelf -l es
pybabel update  -i bookshelf_shelves/locale/bookshelf.pot -d bookshelf_shelves/locale -D bookshelf
pybabel compile -d bookshelf_shelves/locale -D bookshelf --statistics

Two details decide whether a translation reaches the user. The -D bookshelf domain must equal the domain of the factory, because the file name bookshelf.mo is the domain. And pybabel compile and msgfmt skip entries marked #, fuzzy (a guess made by msgmerge or pybabel update when an id changed slightly) until a human reviews the guess and removes the flag; the catalog compiles, the message silently stays in English. pybabel compile -f or msgfmt with fuzzy handling options override that, which is rarely a good idea.

The commands above are shown for the shell and not executed by the examples that follow. To keep every example runnable and self-contained, they build a small catalog from an inline .po text with Babel’s own read_po and write_mo, the Python equivalent of pybabel compile. A real project commits its .po files and compiles them in the build.

import io
import tempfile
from pathlib import Path

from babel.messages.mofile import write_mo
from babel.messages.pofile import read_po

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

BOOKSHELF_ES = HEADER.format(plural="nplurals=2; plural=(n != 1)") + """
msgid "shelf-title"
msgstr "Estantería: ${name}"

msgid "Add a book"
msgstr "Añadir un libro"

msgid "${n} book"
msgid_plural "${n} books"
msgstr[0] "${n} libro"
msgstr[1] "${n} libros"

msgid "Shelf owned by %(name)s"
msgstr "Estantería de %(name)s"

msgid "%(count)s book"
msgid_plural "%(count)s books"
msgstr[0] "%(count)s libro"
msgstr[1] "%(count)s libros"
"""

# a second domain with three plural forms, taken from the Pyramid chapter
INVENTORY_ES = HEADER.format(plural="nplurals=3; plural=n==0 ? 0 : n==1 ? 1 : 2") + """
msgid "item_plural"
msgid_plural ""
msgstr[0] "Sin ejemplares"
msgstr[1] "${number} ejemplar"
msgstr[2] "${number} ejemplares"
"""


def compile_po(po_text, locale_dir, locale, domain):
    """Compile an inline .po text to locale_dir/<locale>/LC_MESSAGES/<domain>.mo."""
    messages = Path(locale_dir) / locale / "LC_MESSAGES"
    messages.mkdir(parents=True, exist_ok=True)
    catalog = read_po(io.StringIO(po_text), locale=locale, domain=domain)
    with open(messages / f"{domain}.mo", "wb") as fp:
        write_mo(fp, catalog)


locale_dir = Path(tempfile.mkdtemp(prefix="shelves-locale-"))
compile_po(BOOKSHELF_ES, locale_dir, "es", "bookshelf")
compile_po(INVENTORY_ES, locale_dir, "es", "inventory")
print(sorted(p.relative_to(locale_dir).as_posix() for p in locale_dir.rglob("*.mo")))
['es/LC_MESSAGES/bookshelf.mo', 'es/LC_MESSAGES/inventory.mo']

Activating Translation

Two things must be true before anything is translated: a translation directory is registered and the request’s locale name is set. config.add_translation_dirs registers directories, given either as absolute paths or as asset specifications (package:folder), so that the catalogs travel with the installed package.

from pyramid.config import Configurator
from pyramid.i18n import TranslationStringFactory
from pyramid.response import Response
from webtest import TestApp

_ = TranslationStringFactory("bookshelf")


def shelf(request):
    localizer = request.localizer
    title = localizer.translate(
        _("shelf-title", default="Shelf: ${name}", mapping={"name": "Ficción"})
    )
    n = int(request.params.get("n", 1))
    count = localizer.pluralize(
        "${n} book", "${n} books", n, domain="bookshelf", mapping={"n": n}
    )
    button = localizer.translate(_("Add a book"))
    return Response(
        f"{request.locale_name} | {title} | {count} | {button}",
        content_type="text/plain",
    )


with Configurator(settings={"pyramid.default_locale_name": "en"}) as config:
    config.add_translation_dirs(str(locale_dir))
    # in a project: config.add_translation_dirs("bookshelf_shelves:locale")
    config.add_route("shelf", "/shelf")
    config.add_view(shelf, route_name="shelf")
    app = TestApp(config.make_wsgi_app())

print(app.get("/shelf").text)
print(app.get("/shelf", {"_LOCALE_": "es", "n": 3}).text)
en | Shelf: Ficción | 1 book | Add a book
es | Estantería: Ficción | 3 libros | Añadir un libro

The English request found no catalog at all, so each message is returned as its default text: a missing translation is never an error, which is why you do not need an English .mo when the source strings are English. Behind the scenes request.localizer is created by pyramid.i18n.make_localizer, which walks the registered directories for the folders named after the locale (and, for a name such as es_ES, first for es), reads every .mo it finds there and merges them, one Translations object per domain. The finished Localizer is registered as a utility in the application registry under the locale name, so the .mo files are read once per locale and process. Consequently a catalog compiled while the application is running is not picked up until the process restarts.

Table 3. The Localizer and request attributes
API Behaviour

request.localizer

The Localizer for the request’s locale. A cached property.

request.locale_name

The negotiated locale name. Computed once per request on first access and then stored on the request.

pyramid.i18n.negotiate_locale_name(request)

Runs the negotiator again, bypassing the value stored on the request. It reads _LOCALE_ on the request object again, so it is the function to call after changing the locale in the middle of a request.

localizer.translate(tstring, domain=None, mapping=None)

Translates a translation string or a plain string and interpolates ${} markers. domain and mapping act as overrides, which is how you translate a plain str.

localizer.pluralize(singular, plural, n, domain=None, mapping=None)

Chooses the plural form for n with gettext rules and interpolates the mapping. Metadata on translation-string arguments is ignored.

localizer.locale_name

The locale name this localizer was made for; the same value as request.locale_name.

pyramid.i18n.make_localizer(locale_name, directories)

Builds a localizer without a request, for scripts, background jobs and e-mail.

pyramid.i18n.get_localizer(request) and get_locale_name(request) still exist but have been deprecated since Pyramid 1.5; use the request attributes. The l*gettext methods of the translations class were removed in Pyramid 2.1, because Python removed them from gettext in 3.11.

Directory Order and Overriding Third-Party Catalogs

Libraries that Pyramid applications use ship their own catalogs: Colander and Deform register their messages in the colander and deform domains (the forms page of this section, see Forms with Deform and Colander, wires them in). When two directories hold the same domain and message in the same locale, the one registered first wins by default; a later call is trumped by earlier ones unless it passes override=True. Inside a single call the opposite holds: later items beat earlier ones.

import tempfile
from pathlib import Path
from pyramid.request import Request


def tiny_catalog(label):
    folder = Path(tempfile.mkdtemp(prefix=f"loc-{label}-"))
    compile_po(
        HEADER.format(plural="nplurals=2; plural=(n != 1)")
        + f'\nmsgid "Hello"\nmsgstr "Hola {label}"\n',
        folder, "es", "bookshelf",
    )
    return str(folder)


first, second = tiny_catalog("first"), tiny_catalog("second")

for kwargs in ({}, {"override": True}):
    with Configurator() as config:
        config.add_translation_dirs(first)
        config.add_translation_dirs(second, **kwargs)
    request = Request.blank("/?_LOCALE_=es")
    request.registry = config.registry
    print(kwargs, request.localizer.translate("Hello", domain="bookshelf"))
{} Hola first
{'override': True} Hola second

To fix a wrong Spanish message in Colander, copy that one entry into your own colander.po, compile it into your directory and register your directory before colander:locale or with override=True. A custom catalog directory that merges into an existing domain does not need to be complete; missing messages fall through to the other directories.

The Default Locale Name

The setting pyramid.default_locale_name is the locale used when the negotiator returns None. It defaults to en. It can be passed to the Configurator, as in the example above, written in the .ini file or set through the PYRAMID_DEFAULT_LOCALE_NAME environment variable (see Settings, Environment and ini Files):

[app:main]
use = egg:bookshelf_shelves
pyramid.default_locale_name = es

The value applies to a request whose negotiator found nothing; it does not restrict the locales a negotiator may return and it is not the list of languages your application offers.

Pluralization

English has two plural forms, but Polish has three, Arabic six and Japanese one. gettext solves this with a Plural-Forms header in the catalog that holds the number of forms and an expression that maps a count to the form index. localizer.pluralize evaluates it for n; msginit and pybabel init write the header for the locale, and you write it yourself only when you invent a catalog by hand. The expression lives in the catalog header, so it differs per domain and per locale.

There are two ways to call pluralize, and mixing them up is the commonest mistake:

  • Source text as ids. pluralize("${n} book", "${n} books", n, ...): singular and plural are English texts and both are the msgid pair in the .po file (msgid and msgid_plural). With no catalog the call falls back to ordinary English rules, so it works in the source language without a catalog.

  • Opaque ids. pluralize("item_plural", "", n, ...): the first argument is a message id defined only in the catalogs, and the second argument is ignored. Every locale, including English, needs a catalog that defines the message, because there is no text to fall back to.

def items(localizer, n):
    return localizer.pluralize(
        "item_plural", "", n, domain="inventory", mapping={"number": n}
    )


def books(localizer, n):
    return localizer.pluralize(
        "${n} book", "${n} books", n, domain="bookshelf", mapping={"n": n}
    )


with Configurator(settings={"pyramid.default_locale_name": "en"}) as config:
    config.add_translation_dirs(str(locale_dir))
registry = config.registry

for locale in ("es", "en"):
    request = Request.blank(f"/?_LOCALE_={locale}")
    request.registry = registry
    loc = request.localizer
    print(locale, [items(loc, n) for n in (0, 1, 5)], [books(loc, n) for n in (1, 2)])
es ['Sin ejemplares', '1 ejemplar', '5 ejemplares'] ['1 libro', '2 libros']
en ['item_plural', 'item_plural', '${number} items'] ['1 book', '2 books']

The English row shows the opaque-id trap: with no English catalog the call returns the raw id item_plural for n equal to 0 and 1 and the (uninterpolated) plural text for other numbers, the gettext fallback. Either ship an English .mo for opaque ids or use source text as ids, which is also easier for translators to read. The ${number} in the output was not interpolated because the fallback path returns the second argument verbatim, which was empty here and replaced by the lookup’s own text; use real source text to avoid the problem. The gettext manual explains the Plural-Forms expressions per language.

Locale Negotiation

The locale negotiator is a callable that receives the request and returns a locale name or None. It is consulted the first time request.locale_name or request.localizer is used, or whenever negotiate_locale_name is called. Exactly one negotiator is active; a later registration replaces the earlier one. If it returns None, Pyramid uses pyramid.default_locale_name, and en if that is not set either.

The Default Negotiator

pyramid.i18n.default_locale_negotiator needs no configuration. It looks, in this order, at the attribute request._LOCALE_, the request parameter _LOCALE_ (query string or form body), and the cookie _LOCALE_, and returns the first non-None value.

flowchart TD A[request.locale_name first used] --> B{negotiator registered?} B -- no --> C{request._LOCALE_ set?} C -- yes --> Z[locale name] C -- no --> D{_LOCALE_ parameter?} D -- yes --> Z D -- no --> E{_LOCALE_ cookie?} E -- yes --> Z E -- no --> F[None] B -- yes --> G[your callable] G --> H{returned None?} H -- no --> Z H -- yes --> F F --> I[pyramid.default_locale_name, else en] I --> Z Z --> J[stored on the request and used to build the Localizer]

The default negotiator does not read the Accept-Language header, does not look at the URL and does not check that the value is a language you support. The first two limitations are fixed by a custom negotiator; the third is a security issue explained next.

Validate What the Negotiator Returns

The locale name is user input. With the default negotiator any request can send ?_LOCALE_=anything, and Pyramid accepts it: it builds a Localizer for that exact string and registers it in the application registry, where it stays for the life of the process. A client that varies the value therefore grows the registry without bound, and the name also flows into request.locale_name, <html lang>, Babel’s Locale.parse (which raises UnknownLocaleError for an unknown language, a 500) and, through make_localizer, into a file system path os.path.join(directory, name) (Pyramid resolves it with realpath and only reads .mo files below an LC_MESSAGES folder, but a name should never steer a path).

from pyramid.interfaces import ILocalizer


def localizer_count(registry):
    return len(list(registry.getUtilitiesFor(ILocalizer)))


with Configurator() as config:
    config.add_translation_dirs(str(locale_dir))
    config.add_route("shelf", "/shelf")
    config.add_view(shelf, route_name="shelf")
open_app = TestApp(config.make_wsgi_app())

print("localizers before:", localizer_count(config.registry))
for i in range(5):
    open_app.get("/shelf", {"_LOCALE_": f"junk{i}"})
print("localizers after five junk locales:", localizer_count(config.registry))
localizers before: 0
localizers after five junk locales: 5

Pyramid deliberately has no API for "the languages available". The documentation explains why: the languages a deployment supports are a product decision, independent of which translation files happen to be on disk, because translations may exist but be incomplete or cover only some domains. The application owns the list, and the suggested way is a setting that a deployer can edit.

A Custom Negotiator

The negotiator below implements the policy most sites want: an explicit language in the URL first (/es/shelf), then a language the user chose earlier and stored in a cookie, then the browser’s Accept-Language, then the default. Every candidate is checked against the available_languages setting before it is returned, so the registry can never see anything else.

from pyramid.settings import aslist


def negotiator(request):
    settings = request.registry.settings
    available = aslist(settings["available_languages"])
    # matchdict is None until a route has matched (for example in a NewRequest subscriber)
    candidates = [
        (request.matchdict or {}).get("lang"),
        request.cookies.get("lang"),
    ]
    for candidate in candidates:
        if candidate in available:
            return candidate
    # RFC 4647 lookup; WebOb needs a default and does not accept None
    return request.accept_language.lookup(
        available, default=settings["pyramid.default_locale_name"]
    )


def switch(request):
    language = request.matchdict["lang"]
    response = Response(f"switched to {request.locale_name}", content_type="text/plain")
    response.set_cookie("lang", language, max_age=31536000, samesite="Lax")
    response.vary = ("Accept-Language", "Cookie")
    response.content_language = request.locale_name
    return response


settings = {
    "available_languages": "en es",
    "pyramid.default_locale_name": "en",
}
with Configurator(settings=settings, locale_negotiator=negotiator) as config:
    config.add_translation_dirs(str(locale_dir))
    config.add_route("prefixed", "/{lang}/shelf")
    config.add_route("switch", "/language/{lang}")
    config.add_route("plain", "/shelf")
    config.add_view(shelf, route_name="prefixed")
    config.add_view(shelf, route_name="plain")
    config.add_view(switch, route_name="switch")
    nego_app = TestApp(config.make_wsgi_app())

checks = [
    ("/es/shelf", {}),
    ("/fr/shelf", {}),
    ("/shelf", {"Accept-Language": "es-MX,es;q=0.8,en;q=0.5"}),
    ("/shelf", {"Accept-Language": "de, fr;q=0.5"}),
    ("/shelf", {}),
]
for path, headers in checks:
    print(path, headers.get("Accept-Language", "-"), "->", nego_app.get(path, headers=headers).text)
/es/shelf - -> es | Estantería: Ficción | 1 libro | Añadir un libro
/fr/shelf - -> en | Shelf: Ficción | 1 book | Add a book
/shelf es-MX,es;q=0.8,en;q=0.5 -> es | Estantería: Ficción | 1 libro | Añadir un libro
/shelf de, fr;q=0.5 -> en | Shelf: Ficción | 1 book | Add a book
/shelf - -> en | Shelf: Ficción | 1 book | Add a book

The registration can be written three ways, all equivalent: the locale_negotiator= argument of Configurator, as above, config.set_locale_negotiator(negotiator), or a dotted name of either. Notes on the code:

  • request.accept_language.lookup(...) is WebOb’s RFC 4647 lookup: it tries the language ranges in the header in order of quality and truncates a regional tag (es-MX) to match an available es. The older best_match method is deprecated in WebOb 1.8 and warns; the pyramid_jinja2 documentation example still uses it.

  • A language switcher is a route that sets the cookie. samesite="Lax" keeps the cookie off cross-site subrequests; it holds no secret, so it does not need httponly, but nothing reads it from JavaScript either.

  • Vary: Accept-Language, Cookie tells shared caches that the body depends on both; Content-Language records which language the body is in. A page that is cached without Vary shows the wrong language to the next visitor. Static assets are not affected.

Use request.matchdict carefully: it only exists once the router has matched a route, so a negotiator that runs earlier (for instance from a NewRequest subscriber that touches request.localizer) must treat it as optional, as above.

Setting the Locale Without a Negotiator

If the default negotiator suffices, any code that runs before the first translation may set request._LOCALE_ directly, which is the top of its precedence list; a NewRequest subscriber can implement a policy such as "the signed-in user’s preferred language" in a few lines. After request.locale_name has been read it is cached on the request, so changing _LOCALE_ afterwards has no effect on it (and request.localizer is cached too); that is the case in which to call negotiate_locale_name or to set the value earlier.

from pyramid.events import NewRequest
from pyramid.i18n import negotiate_locale_name

USER_LANGUAGES = {"ana": "es", "bob": "en"}


def user_language(event):
    user = event.request.params.get("user")
    if user in USER_LANGUAGES:
        event.request._LOCALE_ = USER_LANGUAGES[user]


with Configurator() as config:
    config.add_translation_dirs(str(locale_dir))
    config.add_subscriber(user_language, NewRequest)
    config.add_route("shelf", "/shelf")
    config.add_view(shelf, route_name="shelf")
    user_app = TestApp(config.make_wsgi_app())

print(user_app.get("/shelf", {"user": "ana"}).text)
print(user_app.get("/shelf", {"user": "bob"}).text)

request = Request.blank("/")
request.registry = config.registry
first = request.locale_name                  # stored on the request: "en"
request._LOCALE_ = "es"
print(first, request.locale_name, negotiate_locale_name(request))
es | Estantería: Ficción | 1 libro | Añadir un libro
en | Shelf: Ficción | 1 book | Add a book
en en es

Translating in Templates

Pyramid has no template engine, so what a template can do depends on the binding. The three maintained ones differ.

Table 4. Translation support per template binding
Binding Behaviour Extraction

Chameleon (pyramid_chameleon)

A translation string rendered by ${}, tal:content, tal:replace or tal:attributes is translated automatically; the i18n: namespace (i18n:translate, i18n:domain, i18n:attributes) also consults Pyramid’s catalogs. It does not use zope.i18n.

pot-create (Lingua)

Jinja2 (pyramid_jinja2)

The jinja2.ext.i18n extension is always active. It provides _(), gettext, ngettext and {% trans %}, backed by the request’s localizer. A TranslationString is not translated automatically.

pybabel extract (Babel) with a [jinja2: ...] mapping

Mako (pyramid_mako)

No built-in support; the Pyramid Community Cookbook has a recipe that adds it.

Mako’s Babel and Lingua plugins

Jinja2

pyramid_jinja2 installs its own GetTextWrapper (pyramid_jinja2.i18n) as the gettext implementation of the Jinja2 environment. The wrapper finds the current request on the thread-local stack and calls localizer.translate and localizer.pluralize with the Jinja2 domain, which is the name of the package that included pyramid_jinja2, or messages when there is none. Set the domain explicitly so it matches your catalogs:

Table 5. Jinja2 internationalization settings
Setting Meaning

jinja2.i18n.domain

The gettext domain the Jinja2 calls use. Defaults to the including package’s name, else messages.

jinja2.i18n_extension

The extension to activate in place of jinja2.ext.i18n.

jinja2.i18n.gettext

A subclass of pyramid_jinja2.i18n.GetTextWrapper (instance, class or dotted name) that overrides gettext and ngettext.

jinja2.newstyle

Enables Jinja2’s new-style gettext, in which placeholders inside _() are formatted by the call itself.

from pyramid.config import Configurator

tpl_dir = Path(tempfile.mkdtemp(prefix="shelves-templates-"))
(tpl_dir / "shelf.jinja2").write_text(
    """<html lang="{{ request.locale_name }}">
<h1>{{ _("Add a book") }}</h1>
<p>{% trans name=owner %}Shelf owned by {{ name }}{% endtrans %}</p>
<p>{% trans count=n %}{{ count }} book{% pluralize %}{{ count }} books{% endtrans %}</p>
<p>{{ request.localizer.translate(title) }}</p>
</html>
"""
)


def jinja_view(request):
    return {
        "owner": "Ana",
        "n": int(request.params.get("n", 1)),
        "title": _("shelf-title", default="Shelf: ${name}", mapping={"name": "Ficción"}),
    }


settings = {"jinja2.directories": str(tpl_dir), "jinja2.i18n.domain": "bookshelf"}
with Configurator(settings=settings) as config:
    config.include("pyramid_jinja2")
    config.add_translation_dirs(str(locale_dir))
    config.add_route("jinja", "/jinja")
    config.add_view(jinja_view, route_name="jinja", renderer="shelf.jinja2")
    jinja_app = TestApp(config.make_wsgi_app())

print(jinja_app.get("/jinja", {"_LOCALE_": "es", "n": 2}).text)
<html lang="es">
<h1>Añadir un libro</h1>
<p>Estantería de Ana</p>
<p>2 libros</p>
<p>Estantería: Ficción</p>
</html>

Three details of Jinja2 messages. {% trans %} produces msgids with Python %(name)s placeholders (visible in the catalog above), whereas translation strings use ${name}; both live happily in the same .po file but a translator has to know the two syntaxes. A literal percent sign inside a {% trans %} block must be written %%. And passing a TranslationString to Jinja2 without localizer.translate prints its msgid, as title would have above.

Autoescaping is on in the maintained Jinja2 binding, so a translated message with markup is escaped; translators should not put HTML in messages. Mark only the markup you wrote yourself as safe, and keep dynamic values in placeholders rather than concatenating them into translated sentences, because word order differs between languages.

Chameleon

Chameleon translates translation strings without any extra call, so a view may return them unconverted in its dictionary.

(tpl_dir / "shelf.pt").write_text(
    """<html xmlns:i18n="http://xml.zope.org/namespaces/i18n"
      i18n:domain="bookshelf" lang="${request.locale_name}">
<h1 i18n:translate="">Add a book</h1>
<p>${title}</p>
<p tal:content="title">placeholder</p>
</html>
"""
)

with Configurator() as config:
    config.include("pyramid_chameleon")
    config.add_translation_dirs(str(locale_dir))
    config.add_route("pt", "/pt")
    config.add_view(jinja_view, route_name="pt", renderer=str(tpl_dir / "shelf.pt"))
    pt_app = TestApp(config.make_wsgi_app())

print(pt_app.get("/pt", {"_LOCALE_": "es"}).text)
<html lang="es">
<h1>Añadir un libro</h1>
<p>Estantería: Ficción</p>
<p>Estantería: Ficción</p>
</html>

Chameleon’s i18n:translate takes the element’s own text as the msgid, so pot-create finds Add a book in the template. Outside Pyramid, Chameleon can use zope.i18n; inside Pyramid it does not, and the Pyramid chapter says to use the facilities described on this page instead. A text template (.txt) is translated in the same way.

Mako

Pyramid’s own documentation points to a recipe in the Pyramid Community Cookbook for idiomatic Mako translation, rather than documenting a built-in feature. Because Mako has Lingua and Babel extraction plugins, the same toolchain applies; the integration, which makes the localizer available to the template, is the part the recipe provides.

Dates, Numbers and Currencies

Pyramid does not format dates or currencies. The documentation points to Babel’s Locale class, and the localizer’s locale name is what you pass to it. Babel’s number and date functions accept either a Locale or a locale string and use the CLDR data shipped with Babel.

from datetime import date, datetime
from decimal import Decimal
from zoneinfo import ZoneInfo

from babel import Locale
from babel.dates import format_date, format_datetime
from babel.numbers import format_currency, format_decimal


def formats(request):
    locale = Locale.parse(request.locale_name)       # "es" or "es_ES"; hyphens need sep="-"
    when = datetime(2026, 10, 7, 18, 30, tzinfo=ZoneInfo("UTC"))
    return {
        "date": format_date(date(2026, 10, 7), format="long", locale=locale),
        "time": format_datetime(
            when, format="short", tzinfo=ZoneInfo("Europe/Madrid"), locale=locale
        ),
        "number": format_decimal(Decimal("1234567.891"), locale=locale),
        "price": format_currency(Decimal("1234.5"), "EUR", locale=locale),
    }


for locale_name in ("en", "es_ES", "de_DE"):
    request = Request.blank(f"/?_LOCALE_={locale_name}")
    request.registry = registry
    print(locale_name, formats(request))
en {'date': 'October 7, 2026', 'time': '10/7/26, 8:30 PM', 'number': '1,234,567.891', 'price': '€1,234.50'}
es_ES {'date': '7 de octubre de 2026', 'time': '7/10/26, 20:30', 'number': '1.234.567,891', 'price': '1.234,50 €'}
de_DE {'date': '7. Oktober 2026', 'time': '07.10.26, 20:30', 'number': '1.234.567,891', 'price': '1.234,50 €'}

Pointers for correct output. Store timestamps in UTC and convert with tzinfo= when formatting, using the user’s time zone rather than the server’s; the language does not determine the time zone. Store money as Decimal with an explicit currency code: the locale decides only the format, not the currency. Locale names use an underscore (es_ES) in Pyramid and in Babel’s default parsing but a hyphen (es-ES) in HTTP and HTML; Pyramid’s catalog lookup splits only at an underscore, so a hyphenated name finds no es catalog and is served untranslated. Normalise names to the underscore form in the negotiator and render the hyphen form in <html lang>.

To make the helpers available to every template, add them to the renderer globals with the BeforeRender event (described in Templates):

from pyramid.events import BeforeRender


def add_formatters(event):
    request = event["request"]
    if request is not None:
        locale = Locale.parse(request.locale_name)
        event["format_date"] = lambda value, style="medium": format_date(
            value, format=style, locale=locale
        )
        event["format_money"] = lambda value, currency: format_currency(
            value, currency, locale=locale
        )


# in the application factory: config.add_subscriber(add_formatters, BeforeRender)
print(add_formatters.__name__)
add_formatters

Translating Outside a Request

E-mail jobs, command-line scripts and background tasks have no request, yet they still send localized text to a user. make_localizer builds a localizer from a locale name and a list of directories, which are plain paths here, not asset specifications.

from pyramid.i18n import make_localizer

localizer = make_localizer("es", [str(locale_dir)])
greeting = _("shelf-title", default="Shelf: ${name}", mapping={"name": "Ficción"})
print(localizer.locale_name, localizer.translate(greeting))
print(localizer.pluralize("${n} book", "${n} books", 4, domain="bookshelf", mapping={"n": 4}))
es Estantería: Ficción
4 libros

Create the localizer once per locale and reuse it, because each call reads the .mo files from disk; the registry’s caching applies only to request.localizer. In a command-line script that boots the application, read the directories from the application’s registry instead of repeating them.

Testing Translations

A request in a given language is just a parameter, a cookie or a header, so WebTest covers all three. Two kinds of test pay for themselves: one request per supported language checking a known message, and a check that the catalogs are complete, so a missing or fuzzy translation fails the build instead of reaching production.

from babel.messages.pofile import read_po


def test_languages():
    assert "Añadir un libro" in nego_app.get("/es/shelf").text
    assert "Add a book" in nego_app.get("/fr/shelf").text          # unsupported, so default
    headers = {"Accept-Language": "es"}
    assert "Añadir un libro" in nego_app.get("/shelf", headers=headers).text
    nego_app.set_cookie("lang", "es")
    assert "Añadir un libro" in nego_app.get("/shelf").text
    nego_app.reset()
    assert "Add a book" in nego_app.get("/shelf").text             # no cookie after the reset
    response = nego_app.get("/language/es")
    assert "lang=es" in response.headers["Set-Cookie"]
    assert response.headers["Vary"] == "Accept-Language, Cookie"
    assert response.headers["Content-Language"] == "es"


def untranslated(po_text):
    catalog = read_po(io.StringIO(po_text))
    return [m.id for m in catalog if m.id and (not m.string or m.fuzzy)]


def test_catalog_is_complete():
    assert untranslated(BOOKSHELF_ES) == []
    assert untranslated(BOOKSHELF_ES + '\nmsgid "Delete shelf"\nmsgstr ""\n') == ["Delete shelf"]


test_languages()
test_catalog_is_complete()
print("both tests passed")
both tests passed

In a real project, read the committed .po files in test_catalog_is_complete instead of an inline text, and run msgfmt --check (or pybabel compile --statistics) in continuous integration so that syntax errors in a .po file break the build rather than the first Spanish visitor. msgfmt --statistics prints the translated, fuzzy and untranslated counts per catalog.

Packaging and Deployment

  • Ship the .mo files. They are build products, so either commit them or compile in the build and make the package include them: a MANIFEST.in line such as recursive-include bookshelf_shelves/locale *.mo *.po and the matching package-data setting in pyproject.toml (see the packaging notes in Cookiecutter Projects and pserve). A wheel built without them installs an application that silently speaks only the source language.

  • Compile in the image. A multi-stage container build can run pybabel compile or msgfmt in the build stage and copy only the .mo files into the runtime image (see Docker), so the runtime image needs neither Babel nor GNU gettext.

  • Restart workers after changing catalogs. The localizer cache lives in each worker process; with a rolling update on Kubernetes new pods start with the new catalogs.

  • Do not rely on --reload for catalogs. pserve --reload watches Python files and the .ini; a recompiled .mo needs a restart unless you configure the reloader to watch it.

Common Pitfalls

Table 6. Pitfalls and fixes
Symptom Cause and fix

Translations never appear

No translation directory registered, domain name differs from the .mo file name, the .mo was not compiled, or the entry is #, fuzzy. Pyramid reads only .mo files.

shelf-title or another opaque id appears on the page

A TranslationString went to something that does not translate it (JSON renderer, an f-string, Jinja2) or no catalog defines it. Translate with request.localizer.translate, or ship an English catalog for opaque ids.

${name} appears literally

The mapping lacks the key, or the message was formatted with str.format first. Pyramid replaces only markers that the mapping provides.

The locale changed but the page did not

request.locale_name and request.localizer are cached per request. Set request._LOCALE_ before the first translation, or call negotiate_locale_name.

Spanish works for es_ES and not for es-ES

Catalog lookup splits on an underscore. Normalise locale names.

Memory grows with traffic

The default negotiator accepts any _LOCALE_ value and a localizer is cached for each. Validate against the languages you support.

UnknownLocaleError from Babel

A locale name that Babel does not know (a typo, or an attacker’s value) reached Locale.parse.

TypeError from accept_language.lookup

lookup needs a default (or default_tag); None is rejected.

Plural text wrong in Polish or Arabic

The catalog’s Plural-Forms header is missing or wrong, or pluralize was passed an opaque id with no catalog.

A new catalog is ignored until restart

Localizers are cached per locale name for the life of the process.

Messages inserted by concatenation read badly

Word order differs per language. Use one message with ${} placeholders per sentence.

Recent Changes and Outdated Material

  • Pyramid 2.1 removed the l*gettext() methods of the i18n module (removed from Python’s gettext in 3.11) and removed internal uses of deprecated locale and datetime APIs, which reduces deprecation warnings during i18n operations. The i18n API itself (TranslationStringFactory, Localizer, negotiators, add_translation_dirs) did not change.

  • add_translation_dirs(..., override=True) has existed since Pyramid 1.8. get_localizer and get_locale_name have been deprecated since Pyramid 1.5 in favour of request.localizer and request.locale_name.

  • The pyramid_jinja2 documentation still describes the pcreate scaffold (pcreate -s pyramid_jinja2_starter, long gone) and the Babel setuptools commands (python setup.py extract_messages, update_catalog, compile_catalog). Projects that use only pyproject.toml, like the current cookiecutter starter, have no setup.py: call pybabel directly, as shown above. The NewRequest example in that documentation uses WebOb’s deprecated best_match; use lookup.

  • The Pyramid chapter’s Lingua instructions are current (Lingua 4.16.2, released 2026-02-27, supports Python 3.8 and newer), but Lingua cannot read Jinja2 templates; it is Chameleon and Python (and Mako, Zope and ZCML) that it extracts.

  • The Pyramid chapter and some older tutorials import zope.i18n-style names. Pyramid does not use zope.i18n and Chameleon inside Pyramid does not either.

  • The cookiecutter starter does not ship an i18n setup. The pcreate-era Jinja2 starter did (a locale folder, a _LOCALE_ parameter and an extraction configuration); today you add the pieces on this page yourself.

Summary

Translation in Pyramid is a few small parts that you assemble. Mark strings with a TranslationStringFactory, extract and compile them into one .mo file per locale and domain, and register the directory with add_translation_dirs. Let a negotiator you control, validated against the list of supported languages, choose the locale for each request; translate in views with request.localizer, in Chameleon automatically and in Jinja2 through pyramid_jinja2’s gettext wrapper; format dates, numbers and currencies with Babel using the same locale name. Test one request per language and fail the build on untranslated or fuzzy messages.