Skip to content

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, or Accept-Language header) and stores it for the duration of the request
  • TranslationManager — resolves a message key to a localized string for a given locale, then formats it with runtime values
  • translate — the translation entrypoint used throughout your app (conventionally aliased as _); looks up a key against the configured manager using the current locale
  • Formatter — interpolates runtime values into a resolved string (StrFormatter by default, or JinjaFormatter)
  • Translated[T] — a Pydantic field type that holds a translation key and resolves it through translate when 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
  pl.json
// 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)}
$ curl 'localhost:8000/hello?name=Ada&lang=pl'
{"message":"Cześć Ada"}

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:

  1. The ?lang=xx query parameter
  2. The locale cookie
  3. The Accept-Language header, best-first by q value
  4. 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-USen). 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:

  1. If no locale is passed, it reads the current request locale (falling back to default).
  2. If the locale is not in supported_locales, it raises ValueError.
  3. It resolves the key via get_key, walking the locale's fallback chain (the locale, its configured fallbacks, then default). 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).
  4. It interpolates the runtime kwargs through 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.

from fastapi_views.i18n import configure_translations

configure_translations(manager)

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:

{ "greeting": "Hello {name}" }
_("greeting", name="Ada")  # -> "Hello Ada"

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(),
)
{ "items_count": "You have {{ count }} item{{ 's' if count != 1 }}" }
_("items_count", count=3)  # -> "You have 3 items"

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:

@app.get("/items/{id}")
async def get_item(id: int) -> Item:
    return Item(name="errors.not_found")
$ curl 'localhost:8000/items/1?lang=pl'
{"name":"Nie znaleziono żądanego elementu"}

!!! 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:

from fastapi_views.i18n import override_locale

with override_locale("pl"):
    ...  # translate / Translated fields resolve against "pl" inside this block