Docs / fastkit-i18n / Middleware

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.

Zero dependencies Any ASGI framework FastAPI Starlette Litestar

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.

Pure ASGI

Speaks scope/receive/send directly — no Starlette Request object needed.

Three detection sources

Header, query parameter, or cookie — in a fixed, predictable priority.

Never leaks between requests

Token-based reset guarantees one request's locale can't bleed into the next.

Shared context

Sets the same locale that _() and TranslatableMixin already read from.

Quick Example

python
from fastapi import FastAPI
from fastkit_i18n import LocaleMiddleware, _

app = FastAPI()
app.add_middleware(LocaleMiddleware)

@app.get("/")
def root():
    return {"message": _("messages.welcome")}
bash
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:

1. Accept-Language header
Accept-Language: es-ES → "es" (first 2 characters)
↓ if absent
2. ?lang= query parameter
GET /products?lang=fr → "fr"
↓ if absent
3. locale cookie
Cookie: locale=de → "de"
↓ if absent
4. App-wide default
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:

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

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

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

python
# 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 BackgroundTasks callback 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:

python
# 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

LocaleMiddleware(app)
Standard ASGI middleware constructor — takes the next ASGI app/callable to wrap. No configuration options; behavior is customized via set_default_locale() (see Custom Default Locale).
SourceFormat readExample
Accept-Language headerFirst 2 charactersAccept-Language: es-ESes
?lang= query parameterUsed as-is?lang=frfr
locale cookieUsed as-isCookie: locale=dede

Complete Example

A locale switcher endpoint that sets a cookie, plus a page that reads it back:

python
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}
bash
# 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!", ...}