Internationalization (i18n)
FastAPI Views ships a small internationalization layer that detects the caller's preferred locale per request and translates message keys into localized strings. It is built from a few composable pieces:
LocaleMiddleware— detects the request locale (query param, cookie, orAccept-Languageheader) and stores it for the duration of the requestTranslationManager— resolves a message key to a localized string for a given locale, then formats it with runtime valuestranslate— the translation entrypoint used throughout your app (conventionally aliased as_); looks up a key against the configured manager using the current localeFormatter— interpolates runtime values into a resolved string; opt in withStrFormatter(str.format) orJinjaFormatter(Jinja2 + Babel)Translated[T]— a Pydantic field type that holds a translation key and resolves it throughtranslatewhen the model is serialized to JSON (TranslatedStris the ready-madeTranslated[str]alias)
Everything except the Jinja2 formatter works with no extra dependencies. JinjaFormatter
and its Babel filters live in fastapi_views.i18n.jinja2 and need the i18n extra:
The current locale is propagated through a context variable owned by the translation
manager, so translate works anywhere in your request handling — no need to thread a
request or locale argument around.
Out of the box, the detail of every built-in error response is wrapped in translate
(see Error handling with raises), so once you
configure a translation manager your error details are localized automatically. The
problem type and title are not translated. A detail that is not a known key passes
through untouched, so raising NotFound("Item not found.") still returns that exact
sentence.
fastapi_views.i18n exports exactly:
| Import | What it is |
|---|---|
LocaleMiddleware |
per-request locale detection |
TranslationManager |
abstract base for managers |
JsonFilesTranslations, InMemoryTranslations, NoTranslations |
shipped managers |
configure_translations |
register the global manager |
translate |
key → localized string |
get_locale, override_locale |
read / temporarily change the current locale |
Translated, TranslatedStr |
translatable Pydantic field types |
Formatter and StrFormatter live in fastapi_views.i18n.formatter; JinjaFormatter,
build_environment, pluralize and with_locale in fastapi_views.i18n.jinja2.
Quick start
Create a directory of per-locale JSON files:
// translations/en.json
{
"greeting": "Hello {name}",
"errors": {
"not_found": "The requested item was not found"
}
}
// translations/pl.json
{
"greeting": "Cześć {name}",
"errors": {
"not_found": "Nie znaleziono żądanego elementu"
}
}
Wire a JsonFilesTranslations manager into configure_app. This both installs
LocaleMiddleware and registers the manager as the global translation source:
from fastapi import FastAPI
from fastapi_views import configure_app
from fastapi_views.i18n import JsonFilesTranslations, translate as _
from fastapi_views.i18n.formatter import StrFormatter
app = FastAPI()
configure_app(
app,
translation_manager=JsonFilesTranslations(
"./translations",
default="en",
supported_locales=["en", "pl"],
formatter=StrFormatter(),
),
)
@app.get("/hello")
async def hello(name: str):
# Resolved against the locale detected for the current request
return {"message": _("greeting", name=name)}
translate("greeting", name="Ada") looks up the greeting key for the request's
locale and formats it with name="Ada". Keys may be dotted to traverse nested objects
(_("errors.not_found")). The formatter is what turns {name} into Ada — without
one the resolved text is returned verbatim, so pass StrFormatter() (or
JinjaFormatter) whenever your messages take runtime values.
Note
If no translation manager has been configured, translate returns its argument
unchanged. This makes it safe to wrap strings in _(...) everywhere, even before you
add translations.
translate is happy with either a lookup key or a literal message. A key that is missing
everywhere degrades to the text after its last ., but that
fallback only kicks in for strings that actually look like dotted keys — free text is
returned unchanged, so _("Item not found.") and _("3.5 items") survive intact. Wrapping
literal sentences in _(...) is safe.
Warning
The one ambiguous case is a literal that happens to be shaped like a dotted key —
_("some.thing") is treated as the key some.thing and degrades to "thing" when it
is not found. Give such messages a real key, or reword them.
How the locale is detected
LocaleMiddleware resolves the locale for each request by checking, in order:
- The
?lang=xxquery parameter - The
localecookie - The
Accept-Languageheader, best-first byqvalue - The configured default locale
Each candidate tag is resolved to a supported locale by the manager's match_supported,
which tries the tag itself, then its configured fallbacks, then its
language subtag (en-US → en). The first source that yields a supported locale wins;
an unresolvable source is skipped, and * or q=0 entries in Accept-Language are
ignored. Every response carries the resolved locale in a Content-Language header.
When the locale comes from the ?lang= query parameter, the middleware also sets a
locale cookie holding the resolved locale (?lang=pl-PL stores pl), with a
30-day max-age, Path=/, SameSite=Lax, and Secure when the request scheme is
https — so the choice sticks for subsequent requests. Non-HTTP scopes (WebSocket,
lifespan) pass through untouched.
configure_app(translation_manager=...) installs the middleware for you and registers the
same manager as the global translation source. To install it manually, pass the manager as
its single argument — use NoTranslations when you want locale detection without
translation lookup:
from fastapi_views.i18n import LocaleMiddleware, NoTranslations, configure_translations
manager = NoTranslations(default="en", supported_locales=["en", "pl", "de"])
app.add_middleware(LocaleMiddleware, manager)
configure_translations(manager)
Warning
The middleware stores the locale on the manager instance it was given. Register
that same instance with configure_translations, otherwise the module-level
translate / get_locale helpers read a different (default) manager and always see
the default locale.
Translation managers
A TranslationManager owns the default locale ("en"), the set of supported_locales
(defaults to just the default locale), a formatter (None — no interpolation), and an
optional fallbacks map. All four are keyword-only, and default
must itself be in supported_locales or construction raises ValueError.
format_key is what translate calls:
- If no locale is passed, it reads the current request locale (falling back to
default). - If the locale is not in
supported_locales, it raisesValueError. - It resolves the key via
get_key, walking the locale's fallback chain (the locale, its configured fallbacks, thendefault). If no locale in the chain has the key and the string looks like a dotted lookup key, it falls back to the text after the last.(so_("errors.not_found")degrades to"not_found"rather than raising); anything else is returned unchanged. - It interpolates the runtime
kwargsthrough the formatter — see Formatters — with the resolved locale installed as the current locale, so an explicitlocale=governs locale-aware formatting too, not just the lookup.
"Looks like a dotted lookup key" means two or more .-separated segments, each starting with
a letter or _ (any unicode letter — _("błędy.nie_znaleziono") counts, digits do not) and
otherwise made up of word characters or -. That keeps free text intact: "Item not found.",
"3.5 items", "Requires version 1.2 or newer." and "errors.not found" are all returned
as they came in. This matters because the detail of every built-in error response goes
through translate.
The fallback-chain walk lives in TranslationManager, so every manager — including custom
ones — benefits from it. The two dict-backed managers (JsonFilesTranslations and
InMemoryTranslations) additionally share a private base that provides dotted-key
traversal (_("errors.not_found") walks nested objects) and raises TypeError if a key
resolves to something other than a string.
Three implementations ship with the library, all importable from fastapi_views.i18n.
JsonFilesTranslations
Loads one JSON file per locale (<locale>.json) from dir_name (default
"./translations"), lazily and thread-safely caching each file on first use. The directory
must exist at construction time, otherwise NotADirectoryError is raised. Nested objects
are addressed with dotted keys. If a locale is missing a key (or its file does not exist),
lookups follow the locale's fallback chain.
from fastapi_views.i18n import JsonFilesTranslations
from fastapi_views.i18n.formatter import StrFormatter
manager = JsonFilesTranslations(
"./translations",
default="en",
supported_locales=["en", "pl"],
formatter=StrFormatter(),
)
InMemoryTranslations
Holds translations in a plain nested dict keyed by locale — handy for tests or small
apps. Like JsonFilesTranslations, it supports dotted keys for nested objects and follows
the fallback chain for missing keys.
from fastapi_views.i18n import InMemoryTranslations, configure_translations
from fastapi_views.i18n.formatter import StrFormatter
manager = InMemoryTranslations(
{
"en": {"greeting": "Hello {name}", "errors": {"not_found": "Not found"}},
"pl": {"greeting": "Cześć {name}", "errors": {"not_found": "Nie znaleziono"}},
},
default="en",
supported_locales=["en", "pl"],
formatter=StrFormatter(),
)
configure_translations(manager)
manager.format_key("errors.not_found", locale="pl") # -> "Nie znaleziono"
NoTranslations
A pass-through manager that returns every key unchanged (the full key, not just its last
segment — its get_key never misses, so the fallback chain is never walked). Useful as an
explicit default or in environments where you want locale detection but no translation
lookup. It is also what translate falls back to when no manager has been configured.
Fallback locales
Beyond the implicit default-locale fallback, you can configure explicit per-locale
fallbacks. A fallback maps a locale — or a tuple of locales sharing the same chain — to a
single locale or an ordered list of locales:
manager = JsonFilesTranslations(
"./translations",
default="en",
supported_locales=["en", "de", "de-AT", "pt", "pt-BR", "pt-PT"],
fallbacks={
"de-AT": "de", # single fallback
("pt-BR", "pt-PT"): ["pt"], # shared fallback for several locales
},
)
When resolving a key, the manager walks the locale's fallback chain — the locale
itself, its configured fallbacks (in order), then the default locale — and returns the
first match. For de-AT above the chain is ("de-AT", "de", "en"). Duplicates are
removed, and chains for the supported locales are precomputed at construction.
LocaleMiddleware consults the same fallbacks during detection: a requested tag that is
not directly supported resolves to the first supported locale in its fallback chain,
checked before language-subtag stripping. This accepts tags that subtag stripping can't
map — e.g. fallbacks={"gsw": "de"} serves German to a Swiss-German (gsw) request.
Note
A fallback inherits the priority of the tag that triggered it. With
Accept-Language: gsw, en;q=0.8 and fallbacks={"gsw": "de"}, the request resolves to
de (the substitute for the most-preferred gsw), not the lower-priority en.
Registering a manager without configure_app
configure_app(translation_manager=...) is the usual path, but you can register a manager
directly with configure_translations. Note this only sets the global source used by
translate — it does not install LocaleMiddleware, so add that separately if you
need per-request locale detection.
Custom managers
Subclass TranslationManager and implement get_key to source translations from anywhere
(a database, a remote service, gettext .mo files, …). Raise KeyError for a missing key
to trigger the built-in fallback behaviour.
from fastapi_views.i18n import TranslationManager
class DatabaseTranslations(TranslationManager):
def get_key(self, key: str, *, locale: str) -> str:
row = db.fetch_translation(locale=locale, key=key)
if row is None:
raise KeyError(key)
return row.text
Formatters
After a key is resolved to a string, the manager's formatter interpolates runtime values.
A formatter is any object with a format(text, **kwargs) -> str method — that is the whole
Formatter protocol (fastapi_views.i18n.formatter.Formatter), so a plain class or even a
SimpleNamespace will do.
There is no formatter by default (formatter=None): the resolved text is returned as
is, placeholders included. Pass one explicitly to get interpolation.
Note
Formatting never breaks a response. If format raises — a missing str.format key, a
Jinja UndefinedError, a syntax error in a message — the exception is logged as a
warning on the translations.manager logger and the unformatted text is returned.
StrFormatter
Uses Python's str.format, so placeholders are written with braces:
_("greeting", name="Ada") # -> "Hello Ada"
_("greeting") # -> "Hello {name}" (KeyError logged, text kept)
JinjaFormatter
Renders each string as a Jinja2 template — useful for conditionals, loops, or the
locale-aware Babel filters below. Requires the i18n extra
(pip install "fastapi-views[i18n]"), which pulls in both jinja2 and babel.
from fastapi_views.i18n import JsonFilesTranslations
from fastapi_views.i18n.jinja2 import JinjaFormatter
manager = JsonFilesTranslations(
"./translations",
supported_locales=["en", "pl"],
formatter=JinjaFormatter(),
)
By default JinjaFormatter uses the environment from build_environment():
StrictUndefined (a missing variable raises) with autoescaping enabled, plus the filters
below. Pass your own Environment as JinjaFormatter(env=...) to change this — note that
a hand-built environment does not get the filters unless you add them yourself.
Locale-aware filters
build_environment installs four Babel-backed filters, all resolving against the
current locale:
| Filter | Backed by |
|---|---|
number |
babel.numbers.format_decimal |
currency |
babel.numbers.format_currency |
date |
babel.dates.format_date, with format="long" |
pluralize |
babel.Locale.plural_form |
{
"summary": "{{ count | number }} {{ count | pluralize({'one': 'produkt', 'few': 'produkty', 'many': 'produktów'}) }} na kwotę {{ total | currency('USD') }}",
"updated": "Ostatnia aktualizacja: {{ updated_at | date }}"
}
with override_locale("pl"):
_("summary", count=5, total=99.95) # -> "5 produktów na kwotę 99,95 USD"
_("updated", updated_at=date(2026, 6, 9)) # -> "Ostatnia aktualizacja: 9 czerwca 2026"
pluralize takes a mapping of CLDR plural categories
("one", "two", "few", "many", "other") to text and picks the variant matching the
number under the active locale's rules, falling back to "other" (then ""). That is why
each language can supply just the forms it needs — English gets by with one/other,
Polish and Russian need few and many too.
The with_locale helper is what injects locale=get_locale() into a Babel call; use it to
add your own locale-aware filters:
from babel.dates import format_datetime
from fastapi_views.i18n.jinja2 import JinjaFormatter, build_environment, with_locale
env = build_environment()
env.filters["datetime"] = with_locale(format_datetime, format="short")
formatter = JinjaFormatter(env)
Note
The filters read the locale from the context (get_locale()), and format_key installs
the locale it resolved for the duration of the interpolation. So an explicit locale=
argument governs both the text and its formatting: translate("n", locale="pl",
v=1234.5) yields "1 234,5", not "1,234.5". The override is scoped to the call and
does not leak — outside it, get_locale() is unchanged. Wrapping a whole block in
override_locale still works when several calls should
share a locale.
Translatable model fields
Wrapping every value in _(...) by hand is fine for ad-hoc strings, but for response
models it is cleaner to mark a field as translatable once and let serialization do the
lookup. Translated[T] is an annotated string type that stores a translation key
and resolves it through translate when the model is dumped to JSON:
from pydantic import BaseModel
from fastapi_views.i18n import Translated, TranslatedStr
class Item(BaseModel):
name: Translated[str]
description: TranslatedStr # same thing, pre-parameterized alias
item = Item(name="errors.not_found", description="errors.not_found")
Translated[T] is Annotated[T, PlainSerializer(translate, ...)] with T bound to str,
so it accepts str subclasses too; TranslatedStr is simply Translated[str].
The translation happens only in JSON mode — the path FastAPI uses to serialize responses — so the raw key is preserved everywhere else:
item.model_dump() # {"name": "errors.not_found"} (raw key, round-trippable)
item.model_dump(mode="json") # {"name": "The requested item was not found"}
item.model_dump_json() # '{"name":"The requested item was not found"}'
The serializer is registered with when_used="json-unless-none", so an optional field
(Translated[str] | None) stays None instead of being translated.
Because the lookup goes through the same translate entrypoint, it uses the locale
detected for the current request, just like a direct _(...) call:
from fastapi_views.i18n import override_locale
with override_locale("pl"):
item.model_dump(mode="json") # {"name": "Nie znaleziono żądanego elementu"}
Returning the model from a route therefore yields localized output automatically:
Note
Translated resolves bare keys only — it does not interpolate runtime values. For
messages that need formatting placeholders, call _("greeting", name=...) directly.
Accessing the current locale
When you need the resolved locale directly — for date formatting, choosing a currency,
etc. — read it from the context with get_locale:
from fastapi_views.i18n import get_locale
@app.get("/whoami")
async def whoami():
return {"locale": get_locale()}
get_locale() returns the configured manager's default locale when none has been set
for the current context (for example, outside a request, or when LocaleMiddleware is not
installed). With no manager configured at all, it falls back to "en".
Locale state lives on the manager itself: set_locale, override_locale, and get_locale
are methods on TranslationManager, each backed by a context variable on that instance.
fastapi_views.i18n exports the module-level get_locale and override_locale helpers,
which delegate to the configured manager — use them anywhere you don't already hold a
manager reference:
from fastapi_views.i18n import override_locale
with override_locale("pl"):
... # translate / Translated fields resolve against "pl" inside this block
override_locale restores the previous locale on exit, which makes it the right tool for
background jobs, tests, or rendering one response in several languages. There is no
module-level set_locale; the unscoped setter is manager.set_locale(...), which
LocaleMiddleware calls once per request.
Complete example
A runnable app combining configure_app, InMemoryTranslations and the Babel-powered
Jinja filters:
"""Internationalization with Babel-powered Jinja translations.
Install the extra and run it:
pip install "fastapi-views[i18n]"
uvicorn examples.i18n:app
Then try the same endpoint in different locales and item counts. Note how each
language pluralizes the noun with its own rules — English has two forms, Polish
and Russian have three (and disagree: 21 is "few" in Polish but "one" in Russian):
curl 'localhost:8000/cart?lang=en&count=1' # 1 item
curl 'localhost:8000/cart?lang=en&count=5' # 5 items
curl 'localhost:8000/cart?lang=pl&count=1' # 1 produkt
curl 'localhost:8000/cart?lang=pl&count=2' # 2 produkty
curl 'localhost:8000/cart?lang=pl&count=5' # 5 produktów
curl 'localhost:8000/cart?lang=ru&count=21' # 21 товар
curl 'localhost:8000/cart/translations?count=5'
The message *text* comes from the per-locale translation tables, while Babel
formats the embedded numbers, currencies and dates — and selects the correct
plural form — for the active locale. Passing `locale=` explicitly (see
`/cart/translations`) governs both: the table it reads and the formatting.
"""
from __future__ import annotations
from datetime import date
from typing import Literal
from fastapi import FastAPI
from fastapi_views import configure_app
from fastapi_views.i18n import InMemoryTranslations
from fastapi_views.i18n import translate as _
from fastapi_views.i18n.jinja2 import JinjaFormatter
# Each language supplies the plural forms its own rules require: English needs
# only "one"/"other"; Polish and Russian add "few" and "many".
translations = InMemoryTranslations(
{
"en": {
"cart": {
"summary": "{{ count | number }}"
" {{ count | pluralize({'one': 'item', 'other': 'items'}) }}"
" totalling {{ total | currency(user_currency) }}.",
"updated": "Last updated on {{ updated_at | date }}.",
},
},
"pl": {
"cart": {
"summary": "{{ count | number }}"
" {{ count | pluralize({'one': 'produkt', 'few': 'produkty', 'many': 'produktów'}) }}"
" na łączną kwotę {{ total | currency(user_currency) }}.",
"updated": "Ostatnia aktualizacja: {{ updated_at | date }}.",
},
},
"ru": {
"cart": {
"summary": "{{ count | number }}"
" {{ count | pluralize({'one': 'товар', 'few': 'товара', 'many': 'товаров'}) }}"
" на сумму {{ total | currency(user_currency) }}.",
"updated": "Последнее обновление: {{ updated_at | date }}",
},
},
},
default="en",
supported_locales=["en", "pl", "ru"],
formatter=JinjaFormatter(),
)
app = FastAPI(title="i18n example")
# Installs LocaleMiddleware and registers the translation manager.
configure_app(app, translation_manager=translations)
@app.get("/cart")
async def cart(count: int = 1, currency: Literal["USD", "PLN"] = "USD"):
# No locale argument needed — translate reads the request's locale.
return {
# `count` drives pluralization; `currency` is data (the cart's currency),
# both passed at runtime. Babel handles the locale-specific formatting.
"summary": _(
"cart.summary", count=count, total=count * 19.99, user_currency=currency
),
"updated": _("cart.updated", updated_at=date(2026, 6, 9)),
}
@app.get("/cart/translations")
async def cart_translations(count: int = 1, currency: Literal["USD", "PLN"] = "USD"):
"""Render the same summary in every supported locale, ignoring the request's."""
return {
locale: _(
"cart.summary",
locale=locale,
count=count,
total=count * 19.99,
user_currency=currency,
)
for locale in translations.supported_locales
}