Middleware

Updated 5 interview angles 4 min read source
On this page6
  1. The two kinds, and why it matters
  2. Order is the reverse of registration
  3. Middleware or dependency?
  4. The request-id pattern
  5. Related
  6. Interview angle

Middleware

Code that runs around every request. FastAPI inherits Starlette’s model, and the interview question is almost always about ordering or about the one middleware base class that quietly breaks streaming.

The two kinds, and why it matters

python
@app.middleware("http")
async def add_timing(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Time"] = f"{time.perf_counter() - start:.3f}"
    return response

That decorator wraps BaseHTTPMiddleware, which is convenient and has a real cost: it buffers the response body. A streaming endpoint stops streaming, and a large download is held in memory.

Pure ASGI middleware has no such problem, because it passes messages through rather than materialising a response:

python
class Timing:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)
        start = time.perf_counter()

        async def wrapped(message):
            if message["type"] == "http.response.start":
                took = f"{time.perf_counter() - start:.3f}"
                message["headers"].append((b"x-time", took.encode()))
            await send(message)

        await self.app(scope, receive, wrapped)

app.add_middleware(Timing)

Rule of thumb: @app.middleware("http") for anything that only reads headers or sets one. Pure ASGI when the endpoint streams, when you touch the body, or when the middleware is on every request in a hot path.

The scope["type"] != "http" guard is not optional — without it the middleware breaks WebSocket and lifespan messages, which is a confusing failure because HTTP keeps working.

Order is the reverse of registration

python
app.add_middleware(GZipMiddleware)      # added first
app.add_middleware(CORSMiddleware)      # added second
app.add_middleware(TrustedHostMiddleware)  # added third

Requests pass through in reverse order of registration — TrustedHost, then CORS, then GZip — and responses come back the other way. So the last one added is the outermost.

That inversion is what breaks CORS on error responses. If an exception is raised inside a middleware registered after CORS, the CORS headers never get attached and the browser reports a CORS failure for what is actually a 500. Add CORSMiddleware last so it is outermost, and the headers survive.

Middleware Register
CORSMiddleware last, so it wraps everything
TrustedHostMiddleware early, to reject bad Hosts cheaply
GZipMiddleware early, so it compresses the final body
Your auth or logging in between

Middleware or dependency?

The distinction people get wrong. Both run before the handler; they differ in what they can see and what they can do.

Middleware Dependency
Runs for every request, including 404s only routes that declare it
Knows the route no yes
Can return a value no yes, injected
Sees the response yes yes, after yield
Per-route control manual path checks APIRouter(dependencies=...)

If you find yourself matching on request.url.path inside middleware, you wanted a dependency. Auth belongs in a router-level dependency, not in middleware doing prefix matching — see Dependencies and injection.

Middleware is right for genuinely global concerns: request ids, timing, GZip, CORS, and catching what escapes everything else.

The request-id pattern

The one middleware nearly every service ends up with, and the reason is Correlation IDs and Trace Context in Async Python:

python
request_id: ContextVar[str] = ContextVar("request_id", default="")

@app.middleware("http")
async def correlate(request: Request, call_next):
    rid = request.headers.get("X-Request-ID") or uuid4().hex
    token = request_id.set(rid)
    try:
        response = await call_next(request)
        response.headers["X-Request-ID"] = rid
        return response
    finally:
        request_id.reset(token)

A ContextVar rather than a global, because concurrent requests share the process. It propagates across await and, importantly, does not propagate into a raw thread — which is why a logging filter reading it from run_in_executor gets an empty string.

Interview angle 5

  • “How does FastAPI middleware ordering work?” - requests pass through in reverse order of registration, so the last one added is outermost and responses unwind the other way. That inversion is why CORSMiddleware goes last: registered earlier, it misses error responses and the browser reports a CORS failure for a 500.
  • “What’s wrong with @app.middleware("http")?” - it wraps BaseHTTPMiddleware, which buffers the response body. A streaming endpoint stops streaming and a large download sits in memory. Write pure ASGI middleware when the endpoint streams or the middleware is in a hot path.
  • “What must pure ASGI middleware do that people forget?” - guard on scope["type"] != "http" and pass everything else through. Without it, WebSocket and lifespan messages break while ordinary HTTP keeps working, which makes it hard to spot.
  • “Middleware or dependency?” - middleware for genuinely global concerns: request ids, timing, GZip, CORS. A dependency when the logic is route-specific or needs to inject a value. Matching on request.url.path inside middleware means you wanted a dependency.
  • “How do you correlate logs across a request?” - a ContextVar set in middleware from an inbound header or a fresh uuid, echoed on the response. It propagates across await but not into a raw thread, which is why executor work loses it.