The FastAPI Request/Response Lifecycle

A FastAPI request is not a function call. It is a descent through half a dozen nested layers, each of which can short-circuit, rewrite or observe what passes through it, followed by an ascent back out through the same layers in reverse. Almost every confusing FastAPI bug — a header that never appears on error responses, a dependency that runs twice, a session that closes too early, a field that vanishes from a response — is a misunderstanding of where in that descent something sits.

This guide is the map. It belongs to Core Architecture and Routing Patterns, and it is the layer beneath Middleware Implementation, Dependency Injection Strategies and Error Handling and Global Exceptions: each of those explains one stage well, and this page explains how the stages compose.

The nested layers a FastAPI request passes through Six concentric rectangles. From outside in: ServerErrorMiddleware, your middleware added last, your middleware added first, ExceptionMiddleware, the router and dependency resolution, and the path operation at the centre. A request travels inward and the response travels back outward through the same layers. ServerErrorMiddleware — last resort 500 Your middleware — added LAST, outermost Your middleware — added FIRST, innermost ExceptionMiddleware — your handlers Router → dependency resolution Path operation + response_model Descent: outermost first. Ascent: innermost first. ServerErrorMiddleware is always outside everything you add.

Prerequisites

You should be comfortable with the ASGI callable signature — async def app(scope, receive, send) — and with the fact that FastAPI is a subclass of Starlette's Starlette application. Everything below is Starlette machinery that FastAPI decorates rather than replaces. The versions in play here are FastAPI 0.139.2, Starlette 1.3.1 and Pydantic 2.13.4 on Python 3.12; the stack layout has been stable for a long time, but the fine interleaving of teardown discussed later is genuinely version-sensitive.

Stage 0: the ASGI scope

Before any of your code runs, the server (Uvicorn, Hypercorn, Granian) has already parsed the request line and headers and built a scope dictionary: type, http_version, method, path, raw_path, query_string, headers as a list of byte pairs, client, server, and an app reference. The body is deliberately not in the scope. It is available only by awaiting receive(), which yields http.request messages until one arrives with more_body false.

This matters more than it looks. The body is a stream that can be consumed exactly once unless someone caches it. Any layer that awaits it — a middleware that wants to log payloads, for example — has taken it away from everyone downstream unless it puts it back. That is the mechanical reason the advice in Middleware vs Dependencies: When to Use Which pushes body inspection into dependencies, where FastAPI has already cached the parsed body on the Request object.

The scope at this point also has no route and no path_params. Routing has not happened. Middleware that wants to label a metric with the route template rather than the raw path cannot read it on the way in — a constraint that surprises people building Prometheus exporters.

Stage 1: the middleware stack, and why order inverts

Starlette does not run middleware from a list at request time. It builds a single nested ASGI callable once, lazily, on the first request (or when you access app.middleware_stack). The construction is, in essence:

# Conceptually what Starlette's build_middleware_stack() does.
stack = [ServerErrorMiddleware] + self.user_middleware + [ExceptionMiddleware]
app = self.router
for cls, args, kwargs in reversed(stack):
    app = cls(app, *args, **kwargs)
self.middleware_stack = app

Two facts follow, and together they explain the inversion everyone trips over.

First, add_middleware does not append — it inserts at position zero of user_middleware. Second, the stack is assembled by wrapping from the innermost outward. The net effect is that each add_middleware call wraps everything registered before it. The last middleware you register is the outermost layer of the onion, so it is the first to see the request and the last to see the response.

This is not arbitrary. It makes the API compositional: whatever you add is guaranteed to observe everything already present. A timing middleware added at the end of create_app() measures the cost of every other middleware, which is exactly what you want. If the ordering were the other way round, adding an observer would require you to remember to register it first, and any later addition would silently escape observation. The ordering rules and their consequences are worked through in detail in Middleware Execution Order.

Note the two bookends Starlette adds itself. ServerErrorMiddleware is always the outermost frame, outside anything you register. ExceptionMiddleware is always the innermost, sitting directly around the router. Neither position is configurable, and both positions are load-bearing — see the exception handling section below.

Stage 2: routing

Once the request reaches ExceptionMiddleware and is passed to the Router, Starlette walks its route table in registration order, calling matches(scope) on each. A route returns a full match, a partial match (path matched but method did not), or no match. The first full match wins; if only partial matches were found, the router raises a 405; otherwise a 404.

Crucially, matching mutates the scope: on a match, the router writes path_params and route into it. From this point inward, and only from this point, request.scope["route"] exists. This is also where mounted sub-applications hand off — a Mount re-writes path and root_path before delegating, which is why the trade-offs in APIRouter Prefix vs Sub-Application Mounting come down to scope rewriting rather than syntax.

FastAPI's APIRoute then takes over with its generated get_route_handler() closure. Everything from here to the serialized response body happens inside that one closure.

Stage 3: dependency resolution

FastAPI walks the dependency graph it computed at import time — not at request time. When you declared the route, FastAPI introspected the signature, resolved every Depends, recursed into their signatures, and froze the result into a Dependant tree. At request time it simply solves that tree, depth first.

The traversal order is: dependencies declared on the application, then on the router, then on the path operation decorator's dependencies=[] list, then the parameters in the handler's own signature — and within each of those, sub-dependencies before their parents. Results are memoised per request keyed by (callable, security_scopes), which is why the same provider used in three places runs once; Dependency Caching and use_cache covers when that memoisation helps and when you need to defeat it.

Request parsing is part of this stage, not a separate one. Path, query, header, cookie and body parameters are validated together, and if any fail, FastAPI raises RequestValidationError before your handler body executes. That single collected error is why a bad request yields every problem at once rather than one at a time — the mechanics are unpacked in Query, Path and Body Parameter Validation.

Dependencies written with yield are entered here but not exited. FastAPI pushes each one onto an AsyncExitStack and unwinds that stack much later, in reverse order of entry. Yield Dependencies and Cleanup Order is the page for the exit half of the story.

Stage 4: the path operation

Your function finally runs. If it is async def, it runs directly on the event loop. If it is a plain def, FastAPI hands it to run_in_threadpool, which borrows a worker from AnyIO's bounded thread pool — the source of the pathology described in Fixing Blocking Calls in Async Routes.

The return value is not the response. It is raw material.

Stage 5: response model validation and serialization

This is the stage people forget exists, and the one that produces the most "but I already returned the right thing" confusion. Whatever your handler returned is passed through serialize_response(), which:

  1. Validates the value against the response field derived from response_model or the return annotation, producing a fresh, clean object. This happens even when you returned a Pydantic model already.
  2. Applies response_model_exclude_unset, _exclude_none, _include and _exclude.
  3. Runs jsonable_encoder to reduce the result to JSON-compatible primitives.
  4. Hands those primitives to the response class, which encodes them to bytes.

The one escape hatch: if your handler returns a Response instance (including JSONResponse, StreamingResponse or FileResponse), FastAPI passes it straight through and none of the above happens. That is a feature, and it is also how people accidentally ship responses that do not match their own OpenAPI schema. Both halves are demonstrated in Response Model and Serialization Order and Streaming and File Responses.

Stage 6: the unwind

The finished response travels back out. Each middleware's post-call_next code runs, innermost first, outermost last. yield dependency teardown and any BackgroundTasks you registered are also drained during this window. The precise interleaving between middleware exits and dependency teardown is subtler than the diagrams suggest, and the next section measures it rather than asserting it.

Where exception handlers actually sit

There are two distinct handler mechanisms, and conflating them causes real outages.

Handlers for specific exception classes — anything you register with @app.exception_handler(SomeError) where SomeError is not literally Exception — are stored in ExceptionMiddleware, the innermost frame. When your handler converts the exception into a response, that response then travels outward through your entire middleware stack normally. Correlation ID headers still get attached. Timing middleware still records the request. HTTPException and RequestValidationError are handled here too, by FastAPI's built-in defaults, which is why Customising Validation Error Responses works by overriding a handler rather than by adding middleware.

The handler for bare Exception is special-cased. Starlette moves it onto ServerErrorMiddleware, the outermost frame. That means a genuinely unhandled error escapes past every middleware you wrote before anything catches it — so their exit code does not run, and any header they would have added is absent. Worse, ServerErrorMiddleware always re-raises after sending its 500, so the server can log it.

The practical consequence: if your incident tooling relies on a middleware attaching a trace ID to responses, that ID is missing from precisely the responses you most want to correlate. The fix is to convert errors into responses inside the innermost layer — register handlers for the concrete exception types you know about, as HTTPException vs Custom Exception Classes recommends — rather than letting them reach the outer edge.

Production implementation: measuring the order

Assertions about ordering are cheap. Here is an app instrumented to record its own lifecycle, with a /trace endpoint that reports what happened during the previous request.

"""Trace the real entry/exit order of ASGI middleware, dependencies and the path operation."""
from typing import Annotated

from fastapi import APIRouter, Depends, FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware

TRACE: list[str] = []


def log(event: str) -> None:
    TRACE.append(event)


class Named(BaseHTTPMiddleware):
    """A BaseHTTPMiddleware that records when it is entered and when it is left."""

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

    async def dispatch(self, request: Request, call_next):
        if request.url.path == "/trace":
            return await call_next(request)
        log(f"middleware {self.name}: enter")
        response = await call_next(request)
        log(f"middleware {self.name}: exit")
        return response


class ResetASGI:
    """Pure ASGI middleware, mounted outermost, that clears the trace per request."""

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

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] == "http" and scope["path"] != "/trace":
            TRACE.clear()
            log("asgi outermost: enter")

            async def send_wrapper(message) -> None:
                if message["type"] == "http.response.body" and not message.get("more_body"):
                    log("asgi outermost: response body sent")
                await send(message)

            await self.app(scope, receive, send_wrapper)
            log("asgi outermost: exit")
            return
        await self.app(scope, receive, send)


app = FastAPI()


async def app_dep() -> str:
    log("dependency app-level: run")
    return "app"


async def router_dep() -> str:
    log("dependency router-level: run")
    return "router"


async def sub_dep() -> str:
    log("dependency sub-dependency: run")
    return "sub"


async def route_dep(sub: Annotated[str, Depends(sub_dep)]):
    log("dependency route-level: before yield")
    try:
        yield f"route<-{sub}"
    finally:
        log("dependency route-level: after yield (cleanup)")


router = APIRouter(dependencies=[Depends(router_dep)])


@router.get("/hello")
async def hello(dep: Annotated[str, Depends(route_dep)]) -> dict[str, str]:
    log("path operation: body runs")
    return {"dep": dep}


app.include_router(router, dependencies=[Depends(app_dep)])


@app.get("/trace")
async def trace() -> dict[str, list[str]]:
    return {"order": list(TRACE)}


# Added first -> ends up INNERMOST of the three BaseHTTPMiddleware layers.
app.add_middleware(Named, name="added-1st")
app.add_middleware(Named, name="added-2nd")
app.add_middleware(Named, name="added-3rd")
# Added last -> outermost of everything.
app.add_middleware(ResetASGI)

Real output, captured by running this app in-process (_verify/examples/arch-lifecycle-order.py):

$ GET /hello
200 OK
{
  "dep": "route<-sub"
}

$ GET /trace
200 OK
{
  "order": [
    "asgi outermost: enter",
    "middleware added-3rd: enter",
    "middleware added-2nd: enter",
    "middleware added-1st: enter",
    "dependency app-level: run",
    "dependency router-level: run",
    "dependency sub-dependency: run",
    "dependency route-level: before yield",
    "path operation: body runs",
    "middleware added-1st: exit",
    "middleware added-2nd: exit",
    "dependency route-level: after yield (cleanup)",
    "middleware added-3rd: exit",
    "asgi outermost: response body sent",
    "asgi outermost: exit"
  ]
}

Read the entry half top to bottom: registration order 1st, 2nd, 3rd produced execution order 3rd, 2nd, 1st. The inversion is not folklore. Dependencies then resolve outermost-declaration-first — application, then router, then route — with sub-dependencies ahead of the parents that need them.

The exit half contains the genuinely surprising result. The yield teardown does not run neatly after all middleware or neatly before them; it lands between added-2nd and added-3rd. That is a scheduling artefact of BaseHTTPMiddleware, which returns a streaming response from call_next: the inner layers resume as soon as the response headers are available, while teardown is driven by the exhaustion of the body stream. The reliable guarantees are narrower than a diagram implies: teardown runs after the path operation, and it completes before the outermost ASGI layer returns. Do not build logic that depends on teardown happening before or after any particular middleware. If you need a strict ordering, own the resource inside the layer that must see it closed.

Production implementation: proving where handlers sit

The second example separates the two handler mechanisms empirically. It has an ordinary BaseHTTPMiddleware, a pure-ASGI middleware registered last (so it is the outermost user frame), and a wrapper around the whole ASGI app — which is the only way to observe from outside ServerErrorMiddleware.

"""Where exception handlers sit relative to user middleware, proven by what still runs."""
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

TRACE: list[str] = []


class QuotaExceeded(Exception):
    def __init__(self, remaining: int) -> None:
        self.remaining = remaining


class Recorder(BaseHTTPMiddleware):
    """An ordinary user middleware: does its exit work only if nothing escapes past it."""

    async def dispatch(self, request: Request, call_next):
        if request.url.path == "/trace":
            return await call_next(request)
        TRACE.append("user middleware: enter")
        response = await call_next(request)
        TRACE.append("user middleware: exit ran")
        response.headers["x-touched-by-middleware"] = "yes"
        return response


class OutermostUserMiddleware:
    """Pure ASGI, added last, so it is the outermost *user* middleware."""

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

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] != "http" or scope["path"] == "/trace":
            await self.app(scope, receive, send)
            return
        TRACE.clear()
        TRACE.append("outermost user middleware: enter")
        try:
            await self.app(scope, receive, send)
        except Exception as exc:
            TRACE.append(f"outermost user middleware: {type(exc).__name__} escaped past it")
            raise
        TRACE.append("outermost user middleware: exit ran")


api = FastAPI()


@api.exception_handler(QuotaExceeded)
async def quota_handler(request: Request, exc: QuotaExceeded) -> JSONResponse:
    TRACE.append("QuotaExceeded handler ran")
    return JSONResponse(status_code=429, content={"error": "quota", "remaining": exc.remaining})


async def require_token(x_token: str | None = None) -> str:
    if x_token != "secret":
        TRACE.append("dependency raised HTTPException")
        raise HTTPException(status_code=401, detail="bad token")
    return x_token


@api.get("/guarded")
async def guarded(token: str = Depends(require_token)) -> dict[str, str]:
    return {"token": token}


@api.get("/quota")
async def quota() -> dict[str, str]:
    TRACE.append("path operation raised QuotaExceeded")
    raise QuotaExceeded(remaining=0)


@api.get("/boom")
async def boom() -> dict[str, str]:
    TRACE.append("path operation raised RuntimeError")
    raise RuntimeError("nothing handles me")


@api.get("/trace")
async def trace() -> dict[str, list[str]]:
    return {"order": list(TRACE)}


api.add_middleware(Recorder)
api.add_middleware(OutermostUserMiddleware)


class BeyondStarlette:
    """Wraps the whole ASGI app, i.e. outside Starlette's own ServerErrorMiddleware.

    ServerErrorMiddleware always re-raises after sending its 500, so a real server can log
    it. This wrapper records that re-raise and swallows it so the transcript can be captured.
    """

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

    async def __call__(self, scope, receive, send) -> None:
        try:
            await self.app(scope, receive, send)
        except Exception as exc:
            TRACE.append(f"outside ServerErrorMiddleware: {type(exc).__name__} re-raised to server")


app = BeyondStarlette(api)

Real output from _verify/examples/arch-lifecycle-exceptions.py:

$ GET /guarded
401 Unauthorized
{
  "detail": "bad token"
}

$ GET /trace
200 OK
{
  "order": [
    "outermost user middleware: enter",
    "user middleware: enter",
    "dependency raised HTTPException",
    "user middleware: exit ran",
    "outermost user middleware: exit ran"
  ]
}

$ GET /quota
429 Too Many Requests
{
  "error": "quota",
  "remaining": 0
}

$ GET /trace
200 OK
{
  "order": [
    "outermost user middleware: enter",
    "user middleware: enter",
    "path operation raised QuotaExceeded",
    "QuotaExceeded handler ran",
    "user middleware: exit ran",
    "outermost user middleware: exit ran"
  ]
}

$ GET /boom
500 Internal Server Error
Internal Server Error

$ GET /trace
200 OK
{
  "order": [
    "outermost user middleware: enter",
    "user middleware: enter",
    "path operation raised RuntimeError",
    "outermost user middleware: RuntimeError escaped past it",
    "outside ServerErrorMiddleware: RuntimeError re-raised to server"
  ]
}

Three requests, three distinct shapes. The HTTPException from a dependency and the custom QuotaExceeded from the handler both produce responses that travel back out through the full middleware stack — user middleware: exit ran appears in both, so the x-touched-by-middleware header is present on those error responses. The RuntimeError does not: it blows straight past both middleware layers, and the only thing that observes it is the wrapper sitting outside Starlette entirely. Note also the body of that 500 — a bare Internal Server Error in plain text, not JSON, because it was produced by ServerErrorMiddleware and never touched your envelope logic. Building a consistent envelope that survives this is the subject of Global Exception Handlers for Consistent API Responses.

Async and performance notes

Every layer in the diagram costs something on every request, and the cost is not uniform.

BaseHTTPMiddleware is the expensive one. It spawns an AnyIO task group per request and pipes the response through a memory object stream so that call_next can hand you a Response object. That is convenient, and it is why the teardown interleaving above is untidy. A pure ASGI middleware — a class with __call__(self, scope, receive, send) that wraps send — adds close to nothing and never buffers. For anything on the hot path, and for anything that must not break streaming, write pure ASGI.

Dependency resolution is cheap but not free, and it is linear in the size of the graph. The graph is built at import time, so the per-request cost is the solving, not the introspection. Where it does become visible is def dependencies: each one costs a thread pool hop, and a route with four sync dependencies makes four hops before your handler starts.

Serialization is frequently the single largest slice of a JSON endpoint's CPU time, especially for list responses of nested models. That cost is real and is measured in Pydantic Model Serialization Performance; Model Construct: When to Skip Validation covers the case where you can prove the data is already valid.

Testing strategy

Lifecycle behaviour is testable, and it is worth testing because it changes under you when someone reorders create_app().

Assert ordering by instrumenting, exactly as the examples above do: keep a module-level list, append from each layer, and assert on the list rather than on prose. TestClient drives the full stack including middleware, so the ordering you observe in a test is the ordering you get in production.

Assert that error responses still carry your cross-cutting headers. A test that requests a route which raises a handled exception and asserts the correlation header is present will catch the day someone converts a specific handler into a bare Exception handler and silently moves it outside the middleware stack.

Use app.dependency_overrides to replace expensive providers without disturbing the graph shape — the override is substituted during resolution, so ordering and caching behaviour are preserved. Overriding Dependencies in Tests covers the pitfalls, chiefly forgetting to clear the dict between tests.

Failure modes and diagnosis

  • A header is missing from 500s but present on 200s. The error escaped to ServerErrorMiddleware. Register a handler for the concrete exception type so it is caught in ExceptionMiddleware instead.
  • Middleware cannot see request.scope["route"]. It runs before routing. Read the scope after call_next, or move the logic to a dependency.
  • The handler gets an empty body. A middleware awaited receive() and did not replay the message. Either buffer and re-inject, or stop reading bodies in middleware.
  • A yield dependency's resource is closed too early or too late. Do not reason about it relative to middleware; see the interleaving above. Scope the resource to the code that uses it.
  • CORS headers absent on error responses. CORSMiddleware must be registered late enough to be outside the layer producing the response; the specifics are in CORS Middleware Configuration.
  • A dependency runs twice. It is declared at two sites with different callables, or use_cache=False is set somewhere.
  • Background tasks never run. They are attached to the response and drained during the unwind; if a middleware replaces the response object, the tasks go with it. When BackgroundTasks Silently Fails enumerates the ways.

Choosing a layer

ConcernRight layerWhy
Correlation ID, timing, request loggingPure ASGI middleware, registered lastMust wrap everything, must not buffer bodies
CORS, GZip, HTTPS redirectStarlette's built-in middlewareProtocol-level, applies before routing
Authentication and authorizationDependencyNeeds parsed inputs and per-route scoping
Database session, HTTP clientyield dependencyDeterministic setup and teardown per request
Converting a domain error to a status codeException handler for that classStays inside the middleware stack
Shaping the response bodyresponse_modelValidated, documented in OpenAPI
Sending large or unbounded payloadsStreamingResponseBypasses buffering and response_model
Connection pools, warm cachesLifespanOnce per process, not per request

The lifespan row is the one people place wrongly most often; Lifespan Events vs Startup/Shutdown explains why the ASGI lifespan protocol runs on a different scope type entirely and never touches the request path.

FAQ

Why does middleware added later run before middleware added earlier? Because add_middleware inserts at the front of the middleware list and Starlette builds the stack by wrapping from the innermost outward. Each add_middleware call wraps everything registered so far, so the last one registered ends up as the outermost wrapper and therefore runs first on the way in and last on the way out.

Where do exception handlers run relative to my middleware? Handlers you register for specific exception classes run inside ExceptionMiddleware, which sits at the innermost edge of the stack, just outside the router. Your middleware therefore still sees the response those handlers produce. The handler for the bare Exception class is different: it is installed on ServerErrorMiddleware, which is outside all user middleware, so your middleware's exit code never runs for a truly unhandled error.

Does response_model validation happen before or after middleware sees the response? Before. Serialization and response model validation happen inside the path operation layer, well within the router. By the time any middleware inspects the response, the body has already been validated, filtered and encoded to JSON bytes.

Is the request body read before my dependencies run? Only if something asks for it. FastAPI resolves the dependency graph first and reads and parses the body as part of solving the path operation's own parameters. A dependency that only inspects headers runs without the body ever being awaited.

Why can middleware not see the matched route or path parameters? Standard user middleware runs before routing, so at that moment the scope has no route or path_params entry. Starlette's router populates those during matching, which happens inside ExceptionMiddleware. Middleware that needs the matched route must read the scope after calling the next layer, or the logic belongs in a dependency instead.