Middleware
LocaleMiddleware detects the caller's locale automatically, once per request, so nothing else in your app needs to call set_locale() manually. It's a pure ASGI middleware — no Starlette or FastAPI dependency required.
Introduction
Most middleware libraries for locale detection are written against a specific framework's Request/Response objects. LocaleMiddleware is written against the raw ASGI protocol instead — scope, receive, send — so it works identically on any ASGI server or framework, with nothing to install beyond fastkit-i18n itself.
Speaks scope/receive/send directly — no Starlette Request object needed.
Header, query parameter, or cookie — in a fixed, predictable priority.
Token-based reset guarantees one request's locale can't bleed into the next.
Sets the same locale that _() and TranslatableMixin already read from.
Quick Example
from fastapi import FastAPI
from fastkit_i18n import LocaleMiddleware, _
app = FastAPI()
app.add_middleware(LocaleMiddleware)
@app.get("/")
def root():
return {"message": _("messages.welcome")}
curl http://localhost:8000/ -H "Accept-Language: es-ES"
# {"message": "¡Bienvenido!"}
Detection Priority
On every HTTP request, LocaleMiddleware checks these sources in order and uses the first one it finds:
Accept-Language: es-ES → "es" (first 2 characters)
GET /products?lang=fr → "fr"
Cookie: locale=de → "de"
get_default_locale() → "en" unless changed
The header is read as the first two characters of its raw value — a simplification, not full RFC quality-value parsing. Accept-Language: fr;q=0.5, en;q=0.9 resolves to "fr" (the first listed language), not "en" (the highest-weighted one). For most apps this matches user expectations closely enough; if you need full quality-value negotiation, detect it yourself and call set_locale() directly instead of using the middleware.
Usage
Register it like any other Starlette middleware:
from fastapi import FastAPI
from fastkit_i18n import LocaleMiddleware
app = FastAPI()
app.add_middleware(LocaleMiddleware)
For a raw ASGI app (or any framework without an add_middleware()-style API), wrap the app directly:
from fastkit_i18n import LocaleMiddleware
async def app(scope, receive, send):
...
app = LocaleMiddleware(app)
Because it's plain ASGI, this same pattern works on Litestar too — wrap the app, or use whatever middleware-registration API the framework provides for ASGI-style middleware classes.
Custom Default Locale
When none of the three detection sources match, the middleware falls back to the app-wide default — the same one used by TranslationManager and TranslatableMixin:
from fastkit_i18n import set_default_locale
set_default_locale("es") # call once at startup
# now a request with no Accept-Language, ?lang=, or cookie resolves to "es"
Request Scoping
The detected locale is set on a contextvars.ContextVar, scoped to the request's async context — and explicitly reset with a token once the request finishes:
# Simplified internal logic:
token = set_locale(detected_locale)
try:
await self.app(scope, receive, send)
finally:
reset_locale(token) # always restores prior state — even on exceptions
Two things this guarantees:
- No leaking between requests. Two concurrent requests in different languages never see each other's locale, even under async concurrency.
- No leaking into background tasks. A locale set for a request doesn't persist into a
BackgroundTaskscallback or anything scheduled outside the request's lifetime.
Non-HTTP Scopes
ASGI scopes aren't only HTTP requests — lifespan events and WebSocket connections use the same protocol. LocaleMiddleware checks scope["type"] and passes anything that isn't "http" straight through, untouched:
# Simplified internal logic:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
No headers are read, no locale is set — the middleware simply gets out of the way for anything it wasn't built to handle.
API Reference
set_default_locale() (see Custom Default Locale).| Source | Format read | Example |
|---|---|---|
Accept-Language header | First 2 characters | Accept-Language: es-ES → es |
?lang= query parameter | Used as-is | ?lang=fr → fr |
locale cookie | Used as-is | Cookie: locale=de → de |
Complete Example
A locale switcher endpoint that sets a cookie, plus a page that reads it back:
from fastapi import FastAPI, Response
from fastkit_i18n import LocaleMiddleware, _, get_locale, set_default_locale
set_default_locale("en")
app = FastAPI()
app.add_middleware(LocaleMiddleware)
@app.get("/")
def root():
# Detected from header, ?lang=, or the "locale" cookie set below
return {"message": _("messages.welcome"), "language": get_locale()}
@app.post("/set-language/{locale}")
def set_language(locale: str, response: Response):
# Persist the user's choice for future requests
response.set_cookie("locale", locale, max_age=60 * 60 * 24 * 365)
return {"locale": locale}
# Default (no header, no cookie)
curl http://localhost:8000/
# {"message": "Welcome!", ...}
# Browser's language wins on the first visit
curl http://localhost:8000/ -H "Accept-Language: es"
# {"message": "¡Bienvenido!", ...}
# Explicit switch — persisted via cookie
curl -X POST http://localhost:8000/set-language/fr -c cookies.txt
# Subsequent requests use the saved cookie
curl http://localhost:8000/ -b cookies.txt
# {"message": "Bienvenue!", ...}
# ?lang= overrides everything, including an existing cookie
curl "http://localhost:8000/?lang=de" -b cookies.txt
# {"message": "Willkommen!", ...}