Middleware Execution Order in FastAPI

Key takeaways:

  • add_middleware pushes onto the front of the stack: the last one added is the outermost and runs first inbound, last outbound.
  • Starlette's exception handling sits inside your middleware stack, between it and the router.
  • An HTTPException raised in middleware is therefore not converted to your status code — it escapes as an unhandled error.
  • A middleware that returns without calling call_next skips everything inner, including your logging and metrics.
  • An exception in one middleware also skips the outbound half of every middleware inside it.

This guide sits under middleware implementation and pins down the ordering rules that decide whether your cross-cutting layers actually see what you think they see.

The Problem This Solves

You have four middlewares: CORS, request tracing, an authentication gate, and a timing layer. In staging, the timing layer stops recording certain requests. A 403 from the auth layer arrives at the browser as a CORS error. An HTTPException(429) raised in a rate-limit middleware surfaces to clients as 500 Internal Server Error.

All three are ordering symptoms, and all three are predictable once you know where each layer sits.

Middleware stack ordering with exception handling inside it Nested layers from outside in: the last middleware added, then earlier ones, then Starlette exception handling, then the router and the endpoint. Requests travel inward and responses travel outward through the same layers in reverse. added last: outermost added earlier added first: innermost exception handlers router and endpoint request travels inward response travels outward
Exception handling is a layer inside your middleware, not around it. Anything raised outside that box is unhandled.

Why It Happens: How the Stack Is Built

FastAPI.add_middleware inserts into user_middleware at index 0. When the application builds its ASGI chain at startup, it composes those in order, so the entry registered most recently ends up wrapping everything registered before it. Around the whole set, Starlette places ServerErrorMiddleware outermost as the last-resort 500 producer, and ExceptionMiddleware innermost, immediately around the router.

That gives a fixed sandwich:

ServerErrorMiddleware          (Starlette, always outermost)
  your middleware, last added
    your middleware, added earlier
      your middleware, added first
        ExceptionMiddleware    (runs your @app.exception_handler functions)
          Router → dependencies → endpoint

Every surprising behaviour on this page falls out of that diagram. Your @app.exception_handler(SomeError) functions live in ExceptionMiddleware, which is inside everything you registered. So they can convert exceptions raised by routes and dependencies — but they are structurally incapable of catching anything raised by your own middleware, which happens further out.

Proving It: a Logged Ordering

This app registers five middlewares that append to a shared list, plus one that short-circuits and one that raises. A /events endpoint reports what the previous request logged.

"""Middleware stack ordering: last added is outermost, and where exception handlers sit."""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

app = FastAPI()

EVENTS: list[str] = []


class Labelled(BaseHTTPMiddleware):
    def __init__(self, app, label: str):
        super().__init__(app)
        self.label = label

    async def dispatch(self, request: Request, call_next):
        EVENTS.append(f"{self.label}: before call_next")
        response = await call_next(request)
        EVENTS.append(f"{self.label}: after call_next (status {response.status_code})")
        response.headers[f"x-{self.label}"] = "seen"
        return response


class ShortCircuit(BaseHTTPMiddleware):
    """Returns without ever calling call_next for one path — everything inner is skipped."""

    async def dispatch(self, request: Request, call_next):
        EVENTS.append("short_circuit: before call_next")
        if request.url.path == "/blocked":
            EVENTS.append("short_circuit: returning early, inner stack never runs")
            return JSONResponse({"blocked": True}, status_code=403)
        return await call_next(request)


class Raiser(BaseHTTPMiddleware):
    """Raises inside dispatch to show it is NOT routed to FastAPI's exception handlers."""

    async def dispatch(self, request: Request, call_next):
        EVENTS.append("raiser: before call_next")
        if request.url.path == "/mw-error":
            EVENTS.append("raiser: raising HTTPException(418) from middleware")
            raise HTTPException(status_code=418, detail="raised in middleware")
        response = await call_next(request)
        EVENTS.append("raiser: after call_next")
        return response


class SafetyNet(BaseHTTPMiddleware):
    """Outermost. Resets the log and catches whatever escapes the rest of the stack."""

    async def dispatch(self, request: Request, call_next):
        EVENTS.clear()
        EVENTS.append("safety_net: before call_next")
        try:
            response = await call_next(request)
        except Exception as exc:
            EVENTS.append(f"safety_net: caught {type(exc).__name__} escaping the stack")
            return JSONResponse({"caught": type(exc).__name__}, status_code=500)
        EVENTS.append(f"safety_net: after call_next (status {response.status_code})")
        return response


class MyDomainError(Exception):
    pass


@app.exception_handler(MyDomainError)
async def domain_error_handler(request: Request, exc: MyDomainError):
    EVENTS.append("exception handler: converting MyDomainError to 422")
    return JSONResponse({"error": "domain", "detail": str(exc)}, status_code=422)


# Added innermost-first. The LAST one added ends up OUTERMOST.
app.add_middleware(Labelled, label="inner")
app.add_middleware(Raiser)
app.add_middleware(ShortCircuit)
app.add_middleware(Labelled, label="outer")
app.add_middleware(SafetyNet)

The ordering rule, confirmed

$ GET /events
200 OK
{
  "events": [
    "safety_net: before call_next",
    "outer: before call_next",
    "short_circuit: before call_next",
    "raiser: before call_next",
    "inner: before call_next",
    "endpoint: /ok body ran",
    "inner: after call_next (status 200)",
    "raiser: after call_next",
    "outer: after call_next (status 200)",
    "safety_net: after call_next (status 200)"
  ]
}

Registration order was inner, Raiser, ShortCircuit, outer, SafetyNet. Execution order inbound is the exact reverse: safety_net first, inner last. Outbound it reverses again, so inner sees the response first and safety_net sees it last — which is why an outermost timing middleware measures everything inside it, and an outermost header-setting middleware wins any conflict over the same header name.

Exception handlers run inside the stack

$ GET /domain-error
422 Unprocessable Entity
{
  "error": "domain",
  "detail": "inventory is negative"
}

$ GET /events
200 OK
{
  "events": [
    "safety_net: before call_next",
    "outer: before call_next",
    "short_circuit: before call_next",
    "raiser: before call_next",
    "inner: before call_next",
    "endpoint: raising MyDomainError",
    "exception handler: converting MyDomainError to 422",
    "inner: after call_next (status 422)",
    "raiser: after call_next",
    "outer: after call_next (status 422)",
    "safety_net: after call_next (status 422)"
  ]
}

The handler ran between the endpoint and the innermost middleware. By the time inner resumes, the exception has already become a 422 response object — every middleware sees a normal response with a normal status code, and none of them needs a try/except. This is why the validation error envelope and other global exception handlers compose cleanly with middleware: they produce responses, not exceptions.

Short-circuiting skips the inner stack entirely

$ GET /blocked
403 Forbidden
{
  "blocked": true
}

$ GET /events
200 OK
{
  "events": [
    "safety_net: before call_next",
    "outer: before call_next",
    "short_circuit: before call_next",
    "short_circuit: returning early, inner stack never runs",
    "outer: after call_next (status 403)",
    "safety_net: after call_next (status 403)"
  ]
}

raiser and inner never appear — not on the way in, not on the way out. The endpoint never ran. Anything you rely on inside that boundary is silently absent: if inner were your access logger, every blocked request would be missing from the log while still appearing in outer's metrics. This is the concrete answer to "why is this request missing from my logs".

An exception in middleware bypasses the middleware inside it

$ GET /mw-error
500 Internal Server Error
{
  "caught": "HTTPException"
}

$ GET /events
200 OK
{
  "events": [
    "safety_net: before call_next",
    "outer: before call_next",
    "short_circuit: before call_next",
    "raiser: before call_next",
    "raiser: raising HTTPException(418) from middleware",
    "safety_net: caught HTTPException escaping the stack"
  ]
}

Two results in one transcript, and both are the ones people get wrong.

The HTTPException(418) did not become a 418. It was raised in raiser, which lives outside ExceptionMiddleware, so nothing translated it. It propagated outward as an ordinary Python exception and was caught by safety_net — and had safety_net not existed, Starlette's ServerErrorMiddleware would have turned it into a bare 500. Raising HTTPException in middleware is a mistake; return a JSONResponse directly instead.

outer and short_circuit never ran their outbound halves. Their after call_next lines are absent, because for them call_next raised rather than returned. Any middleware that sets a response header, records a duration, or resets a contextvar after call_next is skipped for that request. A finally block is the fix:

class TimingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        started = time.perf_counter()
        try:
            return await call_next(request)
        finally:
            # Runs even when an inner layer raises, so the metric is never lost.
            OBSERVED.labels(request.url.path).observe(time.perf_counter() - started)

Choosing an Order

The rule of thumb: register in the order you want them to run innermost-first, or read your add_middleware calls bottom-up.

A stack that works for most production applications, written in registration order:

app.add_middleware(TimingMiddleware)          # innermost: measures handler work only
app.add_middleware(AuthGateMiddleware)        # can short-circuit before the handler
app.add_middleware(TracingMiddleware)         # must wrap auth so rejected requests get an ID
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins)   # outermost

CORSMiddleware last is not a style preference. If an inner layer returns a 403 or raises, that response only carries CORS headers when CORS is outside it — otherwise the browser reports a CORS failure and hides the real status from your frontend, exactly the confusion described in CORS middleware configuration.

Tracing outside auth follows the same logic: a rejected request is the one you most want a correlation ID for, and request-tracing middleware placed inside an auth gate never sees it.

Verification

Assert the order rather than trusting it, using a header each layer stamps:

def test_stack_order(client):
    r = client.get("/health")
    # Every layer ran outbound; a short-circuit or an exception would drop one.
    assert {"x-outer", "x-inner"} <= set(r.headers)


def test_error_response_still_has_cors(client):
    r = client.get("/always-500", headers={"Origin": "https://app.example.com"})
    assert r.status_code == 500
    assert "access-control-allow-origin" in r.headers   # CORS is outside the failure

The second test is the one that catches real incidents, because it fails exactly when a browser would have shown a misleading CORS error instead of your 500.

Trade-Offs and When Not To

Every BaseHTTPMiddleware layer costs something. Each one wraps the downstream app in a task and pipes the response through a stream. Five layers is five wrappers on every request, including health checks. If a concern applies to a handful of routes, a dependency is both cheaper and better scoped — the comparison is in middleware vs dependencies.

Ordering is a hidden coupling. add_middleware calls scattered across an application factory, a plugin, and a conditional if settings.debug block produce an order nobody can read off the page. Register the whole stack in one function, in one file, with a comment stating the intended nesting — the discipline the application factory pattern exists to enforce.

A safety-net middleware can hide errors. SafetyNet above is useful for a demonstration and dangerous in production if it swallows exceptions without re-raising or reporting them. If you add one, log the exception with its traceback and let your error tracker see it before returning the fallback response.

Middleware ordering does not extend into mounted sub-applications. A mount is a separate ASGI app with its own stack. Outer middleware wraps it, but middleware registered on the sub-app runs inside, and the sub-app's own exception handlers apply to its routes — a wrinkle worth remembering when using the mounting approach in modular router organization.

FAQ

Which middleware runs first in FastAPI? The one added last. add_middleware pushes onto the front of the stack, so the final registration becomes the outermost layer and its inbound code runs first. On the way out the order reverses, so it also runs last.

Do FastAPI exception handlers run inside or outside middleware? Inside. Starlette's ExceptionMiddleware sits between your middleware stack and the router, so an exception raised in an endpoint or dependency is converted to a response there and travels outward through your middleware as an ordinary response.

Why does raising HTTPException in middleware return a 500 instead of my status code? Because the handler that translates HTTPException into a response lives inside the middleware stack. An exception raised in middleware is already outside it, so nothing converts it, and it propagates to the server error handler as an unhandled exception.

Why did my logging middleware not log a request? Because a middleware outside it returned a response without calling call_next, or raised. Everything inner is skipped entirely in both cases, which is why a short-circuiting middleware belongs innermost unless you intend to bypass the rest.

Where should CORSMiddleware go relative to my own middleware? Add it last so it becomes the outermost layer. Its headers are then attached to responses produced by anything inside it, including error responses, so a 500 from an inner middleware still reaches the browser as a 500 rather than a CORS failure.

How do I return a proper status code from middleware? Return a JSONResponse with the status you want instead of raising. Middleware is outside the exception-to-response translation layer, so constructing the response yourself is the only reliable way to control what the client receives.