Middleware Implementation in FastAPI

Middleware is the layer wrapped around the entire application, running before a request reaches routing and after a response has been assembled. It is where concerns that are true of every request live: correlation identifiers, CORS negotiation, compression, access logging, total request timing.

Within Core Architecture and Routing Patterns it is the outermost thing you control. It supplies the identifier that error handling stamps into failure responses and that observability and tracing uses to stitch log lines together, and it is deliberately not the same tool as dependency injection, which cannot see anything that failed to match a route. This page covers what the layer is, how to pick an implementation style, and what it costs. The specific builds each have their own guide.

Prerequisites

You should know what an ASGI application is at the level of the scope, receive, send signature, and have a service with at least one route to wrap. Everything measured here ran on FastAPI 0.139.2 with Starlette bundled, on Python 3.12.

Where the middleware stack sits relative to routingNested layers from the server error boundary inward through user middleware to the exception middleware, the router, the dependency graph and finally the endpoint. Requests that match no route turn around at the router.ServerErrorMiddlewareyour middleware (last added is outermost)ExceptionMiddlewareRouter — path match happens heredependency graphthen the endpointa request matching no route turns around at the routerevery enclosing layer still sees it; no dependency does
Middleware encloses routing. Everything inside the router box is per-route; everything outside it applies to all traffic, matched or not.

Core mechanics: a stack that is built once

app.add_middleware(...) does not wrap anything at call time. It pushes an entry onto a list, and the actual chain of wrapped ASGI callables is constructed lazily the first time the application is asked to serve. Once built, it is fixed for the life of the process.

That single design decision produces the layer's most common confusion, so it is worth seeing directly. The example below registers layers before serving, then attempts to register another after a request has been handled:

def build_service() -> FastAPI:
    service = FastAPI()

    @service.get("/ping")
    async def ping() -> dict[str, str]:
        return {"status": "ok"}

    return service


@app.get("/registered-after-serving")
async def registered_after_serving() -> dict[str, Any]:
    service = build_service()
    service.add_middleware(make_marker, label="first")
    before = await call(service)          # This request builds and seals the stack.
    try:
        service.add_middleware(make_marker, label="late")
        late_registration = "accepted"
    except RuntimeError as exc:
        late_registration = f"RuntimeError: {exc}"
    return {
        "middleware_stack_built_after_first_request": service.middleware_stack is not None,
        "first_response": before,
        "late_registration": late_registration,
        "second_response": await call(service),
    }

Running it records this:

$ GET /registered-before-serving
200 OK
{
  "middleware_stack_built": false,
  "response": {
    "status": 200,
    "x-layers": [
      "first",
      "second"
    ]
  }
}

$ GET /registered-after-serving
200 OK
{
  "middleware_stack_built_after_first_request": true,
  "first_response": {
    "status": 200,
    "x-layers": [
      "first"
    ]
  },
  "late_registration": "RuntimeError: Cannot add middleware after an application has started",
  "second_response": {
    "status": 200,
    "x-layers": [
      "first"
    ]
  }
}

Before the first request the stack is genuinely unbuilt, and both layers registered then appear on the response. Afterwards, registration is refused outright with an explicit RuntimeError. The practical rule is that all middleware registration belongs in the application factory, alongside router inclusion and handler registration, executed before anything is served. The related and quieter failure is registering on a different FastAPI instance than the one your server command imports; there is no error for that at all, only middleware that never appears to run.

The order the layers end up in — and why an exception raised inside one of them skips the outbound half of everything nested beneath it — is the subject of middleware execution order.

How a layer communicates with the rest of the request

Middleware runs before any of your typed code, so it has no way to hand a value to a handler through a parameter. There are two carriers, and picking the wrong one is a recurring source of confusion.

The first is request.state, a namespace attached to the ASGI scope. A layer writes request.state.tenant = tenant, and any handler or dependency downstream reads it back. This works, survives the whole request, and is completely untyped: a misspelt attribute is an AttributeError on the first request that hits that path rather than an error your editor or type checker catches. Treat it as a transport, not as an interface, and convert it to a typed value at the earliest opportunity — a one-line dependency that reads request.state.tenant and returns a validated model gives you a documented, overridable seam without giving up the middleware's coverage.

The second carrier is a contextvars.ContextVar. This is what you want when the consumer is not a handler at all — a logging filter, a metrics emitter, an HTTP client's event hook. Asyncio copies the current context into each task, so concurrent requests see their own values without any locking, and code deep in a call stack can read the value without every intervening function having to pass it along. The cost is that the variable must be reset in a finally block; a context variable left set is worse than one never set, because downstream records get stamped with a plausible but wrong value. The full treatment, including how the token-and-reset dance interacts with BaseHTTPMiddleware, is in implementing custom middleware for request tracing.

What middleware cannot do in either case is give a handler a value it is obliged to declare. A handler that needs a tenant should say so in its signature; whether the value ultimately originated in a middleware layer is an implementation detail of the provider that supplies it.

Production implementation: choosing a style

There are two ways to write a layer. BaseHTTPMiddleware gives you a dispatch method receiving a Request and a call_next callable, which is comfortable and reads like ordinary application code. A pure ASGI middleware is a callable taking scope, receive and send, which is lower level and operates on protocol messages.

The folklore is that the first is slow and breaks streaming and the second is fast. That claim is worth measuring rather than repeating, because the real boundary sits somewhere else. The example below serves one streaming endpoint through four different stacks, with an outermost pure-ASGI probe counting the http.response.body messages that actually arrive — the honest measure of whether a layer preserved streaming.

class HeaderASGIMiddleware:
    """Pure ASGI: edits the response-start message and never touches the body."""

    def __init__(self, app) -> None:
        self.app = app

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        async def send_wrapper(message) -> None:
            if message["type"] == "http.response.start":
                message["headers"].append((b"x-style", b"pure-asgi"))
            await send(message)

        await self.app(scope, receive, send_wrapper)


class HeaderHTTPMiddleware(BaseHTTPMiddleware):
    """BaseHTTPMiddleware that only sets a header — it never reads the body."""

    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        response.headers["x-style"] = "basehttp-headers-only"
        return response


class BufferingHTTPMiddleware(BaseHTTPMiddleware):
    """BaseHTTPMiddleware that inspects the body — the common, expensive mistake."""

    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        body = b"".join([section async for section in response.body_iterator])
        return Response(
            content=body,
            status_code=response.status_code,
            media_type=response.media_type,
            headers={"x-style": "basehttp-buffered", "x-body-length": str(len(body))},
        )

The endpoint yields three parts. This is the real recorded result:

$ GET /compare
200 OK
{
  "endpoint_yields_parts": 3,
  "results": [
    {
      "stack": "no-middleware",
      "body": "alpha beta gamma",
      "body_messages_seen_by_outermost_layer": 3,
      "streaming_preserved": true,
      "x-style": "(none)"
    },
    {
      "stack": "pure-asgi-header",
      "body": "alpha beta gamma",
      "body_messages_seen_by_outermost_layer": 3,
      "streaming_preserved": true,
      "x-style": "pure-asgi"
    },
    {
      "stack": "basehttp-header-only",
      "body": "alpha beta gamma",
      "body_messages_seen_by_outermost_layer": 3,
      "streaming_preserved": true,
      "x-style": "basehttp-headers-only"
    },
    {
      "stack": "basehttp-buffered",
      "body": "alpha beta gamma",
      "body_messages_seen_by_outermost_layer": 1,
      "streaming_preserved": false,
      "x-style": "basehttp-buffered"
    }
  ]
}

The result corrects the received wisdom. On FastAPI 0.139.2, a BaseHTTPMiddleware that only touches headers preserves chunking exactly as the bare application and the pure-ASGI layer do. What destroys streaming is reading response.body_iterator, which pulls the entire body into memory before anything is sent — and that is a choice you can make in either style, not a property of one of them.

So the selection rule is about what the layer needs to touch, not about which base class is fashionable. If you need only headers, status codes or timing, either style works and BaseHTTPMiddleware is more readable. If you need to see or rewrite the body, you are buffering by definition, and you should ask whether the concern belongs on specific routes instead. If you need to handle non-HTTP scopes such as WebSocket or lifespan, or you are wrapping something latency-sensitive enough that an extra task group per request matters, write pure ASGI. The tracing build-out that most services need first — accepting or minting a correlation identifier and binding it to a context variable — is developed in implementing custom middleware for request tracing.

Not every layer should be hand-written. CORS in particular is a specification with sharp edges around preflight handling and credentialed requests, and Starlette's CORSMiddleware already implements it; the job is configuring it correctly rather than reimplementing it, which is covered in CORS middleware configuration.

Which concerns actually belong here

A short inventory saves a lot of debate. Four concerns belong in middleware almost unconditionally, because each of them is meaningless unless it covers unmatched and malformed traffic too.

Correlation identifiers come first, since everything else in your observability stack keys off them and a 404 without one is a gap in exactly the traffic you most want to investigate. Access logging and total request counters follow for the same reason: a request rate that silently excludes 404s and 405s will mislead you during precisely the incident where a client is sending bad URLs. Wall-clock timing belongs here because it is the only place that can measure the whole request, including response rendering. And CORS belongs here because preflight requests use the OPTIONS method against paths your router may not answer at all, so a route-scoped implementation would never see them.

Three commonly misplaced concerns belong further in. Authentication is the big one: implemented as middleware it needs a list of exempt paths, and that list is a second description of your routing that drifts from the real one every time somebody adds an endpoint. Per-route rate limiting has the same problem plus a worse one, since the limit usually depends on the identified caller that authentication was supposed to establish. And anything that inspects a request body wants the parsed, validated object that only exists after routing has chosen a model to parse it into.

The tie-breaker question is simple: does this concern need to see traffic that matched no route, or does it need a typed value? The first answer means middleware, the second means a dependency, and the case where you need both is served by a thin layer that stores a value plus a dependency that reads and types it — a pattern examined closely in middleware vs dependencies.

Async and performance notes

Cost here is multiplied by traffic, not by feature count. A layer that awaits an extra network call adds that latency to every endpoint, including the health check your orchestrator hits constantly. Treat outbound I/O in middleware as something requiring justification, and prefer emitting to an in-process buffer that something else drains.

Keep the layer non-blocking. A synchronous call inside a middleware body stalls the event loop for the whole process, not merely for that request, because middleware is not offloaded to the threadpool the way a plain def dependency is. The diagnosis pattern for that class of stall is in fixing blocking calls in async routes.

Buffering deserves its own budget line. Any layer that materialises a response body holds the whole thing in memory for the duration, so a file download or a large export becomes a memory spike proportional to concurrency rather than to file size. If a body-inspecting layer is genuinely required, bound it — skip paths known to stream, and cap the size you are willing to hold.

Testing strategy

Middleware is application-wide, so the natural assertion is that an arbitrary route exhibits its effect. Pick the dullest endpoint you have, precisely because it proves the coverage is not route-specific:

def test_correlation_header_is_universal(client):
    for path in ("/health", "/v1/orders/1", "/no-such-route"):
        response = client.get(path)
        assert "x-request-id" in response.headers   # Including on the 404.

Two properties are worth locking down beyond that. Assert that the layer survives failure — request a route that raises and confirm the header is still present, since a layer that only sets headers on the success path leaves your worst traffic untraceable. And assert registration itself, by checking that the expected classes appear in app.user_middleware on an app built by your factory; that catches the silent case where a refactor moved a registration into a branch that no longer executes.

Middleware cannot be replaced through app.dependency_overrides, which is a real testing asymmetry rather than an oversight. If a concern needs to be stubbed per test, that is strong evidence it should have been a dependency, and the full decision is laid out in middleware vs dependencies.

Failure modes and diagnosis

The layer never runs. Either it was registered after the app started serving, in which case you have a RuntimeError in the logs, or it was registered on a different app object than the one being served. Check that your factory is the only place constructing FastAPI(...).

Streaming responses arrive all at once. Something in the chain is reading body_iterator. Find the layer that does and either remove the inspection or exempt streaming content types.

The handler receives an empty body. A layer consumed the request stream. Move body inspection into a dependency, where the parsed body is already available and typed.

Timings look implausibly small. A timer measuring only around call_next misses response rendering and yield-dependency teardown, both of which happen outside that window. Compare against a total-time measurement taken at the outermost layer.

A 500 appears with a plain-text body. An exception escaped from inside a middleware layer rather than from a route. Handlers registered for specific exception types cannot reach it — only the outermost error boundary can. Have middleware return a response rather than raise.

Headers appear twice. Two layers set the same header without checking, or a layer both mutates the response and constructs a replacement carrying the original headers. Set rather than append for single-valued headers.

Choosing an implementation style

BaseHTTPMiddlewarePure ASGI middlewareA dependency instead
Sees unmatched routesYesYesNo
Sees a typed, parsed bodyNoNoYes
Returns a value to the handlerNoNoYes
Handles lifespan and WebSocket scopesNoYesNo
Replaceable in a testNoNoYes
Preserves a streaming bodyYes, unless you read itYes, unless you read itNot applicable
Reads like ordinary codeYesNoYes
Right forHeaders, timing, loggingProtocol work, hot pathsAuth, tenancy, sessions

FAQ

Is BaseHTTPMiddleware always slower than pure ASGI middleware? No. Measured on FastAPI 0.139.2, a BaseHTTPMiddleware that only sets response headers passes a streaming body through unchanged, chunk for chunk. What collapses streaming is reading response.body_iterator, which buffers the whole body in memory regardless of which style you chose.

Why did the middleware I registered never run? The wrapped application is assembled the first time the app serves anything and cannot be changed afterwards. Registering middleware after that point raises RuntimeError, and registering it on a different app instance than the one your server imports fails silently.

Can middleware read the request body safely? Only with care. The body arrives as a stream that can be consumed once, so reading it in middleware without re-injecting it starves the handler. Body inspection is nearly always better placed in a dependency or the handler itself.

Does middleware run for requests that match no route? Yes. Middleware wraps the router rather than sitting inside it, so it sees 404s, 405s, OPTIONS preflights and requests to mounted sub-applications. That coverage is the main reason to choose middleware over a dependency.

How many middleware layers are too many? Every layer wraps every request, including health checks, so the cost is paid on your least interesting traffic too. Concerns that only apply to some routes are cheaper and clearer as dependencies on those routes.

Should I put authentication in middleware? Usually not. Middleware authentication needs a path allowlist that drifts from your router, produces an untyped value on request.state, and cannot be replaced per test through dependency overrides. Authentication belongs in the injection graph.