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 (StrFormatterby default, orJinjaFormatter)Translated[T]— a Pydantic field type that holds a translation key and resolves it throughtranslatewhen the model is serialized to JSON
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, all built-in error messages are already wrapped in translate, so
once you configure a translation manager your error responses are localized automatically.
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 _
app = FastAPI()
configure_app(
app,
translation_manager=JsonFilesTranslations(
"./translations",
default="en",
supported_locales=["en", "pl"],
),
)
@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")).
!!! 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.
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. When the locale comes from the ?lang= query
parameter, the middleware also sets a locale cookie (30-day max-age) so the choice
sticks for subsequent requests.
configure_app installs the middleware for you from the translation manager. To install it
manually, pass a manager — use NoTranslations when you want locale detection without
translation lookup:
from fastapi_views.i18n import LocaleMiddleware, NoTranslations
app.add_middleware(
LocaleMiddleware,
NoTranslations(default="en", supported_locales=["en", "pl", "de"]),
)
Translation managers
A TranslationManager owns the default locale, the set of supported_locales, a
formatter, and an optional fallbacks map. Its format_key method
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, it falls back to the text after the last.in the key (so_("errors.not_found")degrades to"not_found"rather than raising). - It interpolates the runtime
kwargsthrough the formatter.
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 _DictTranslationManager base that provides
dotted-key traversal (_("errors.not_found") walks nested objects).
Three implementations ship with the library.
JsonFilesTranslations
Loads one JSON file per locale (<locale>.json) from a directory, lazily and
thread-safely caching each file on first use. 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
manager = JsonFilesTranslations(
"./translations",
default="en",
supported_locales=["en", "pl"],
)
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 configure_translations
from fastapi_views.i18n.translations import InMemoryTranslations
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"],
)
configure_translations(manager)
manager.format_key("errors.not_found", locale="pl") # -> "Nie znaleziono"
NoTranslations
A pass-through manager that returns every key unchanged. Useful as an explicit default or in environments where you want locale detection but no translation lookup.
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-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 typing import Any
from fastapi_views.i18n.translations 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 (the Formatter
protocol).
StrFormatter (default)
Uses Python's str.format, so placeholders are written with braces:
JinjaFormatter
Renders each string as a Jinja2 template — useful for conditionals, loops, or filters in
your messages. Requires jinja2 to be installed.
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 a StrictUndefined environment (a missing variable
raises) with autoescaping enabled. Pass your own configured Environment to change this.
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
class Item(BaseModel):
name: Translated[str]
item = Item(name="errors.not_found")
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"}'
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.
The module-level get_locale, set_locale, and override_locale helpers simply delegate
to the configured manager, so use them anywhere you don't already hold a manager reference: