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; opt in with StrFormatter (str.format) or JinjaFormatter (Jinja2 + Babel)
  • Translated[T] — a Pydantic field type that holds a translation key and resolves it through translate when the model is serialized to JSON (TranslatedStr is the ready-made Translated[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:

pip install "fastapi-views[i18n]"

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
  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 _
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)}
$ 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")). 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:

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

  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 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.
  4. It interpolates the runtime kwargs through the formatter — see Formatters — with the resolved locale installed as the current locale, so an explicit locale= 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.

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

from fastapi_views.i18n.formatter import StrFormatter
{ "greeting": "Hello {name}" }
_("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(),
)
{ "items_count": "You have {{ count }} item{{ 's' if count != 1 }}" }
_("items_count", count=3)  # -> "You have 3 items"

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:

@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. 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
    }