Middleware vs Dependencies: When to Use Which

Key takeaways:

  • Middleware wraps the router, so it runs for every request — 404s, mounted sub-applications, OPTIONS preflights.
  • Dependencies attach to routes, so they run only after a route has matched, and never for a 404.
  • Only a dependency can hand the endpoint a typed value; middleware can only stash untyped data on request.state.
  • Only a dependency appears in OpenAPI and can be replaced with dependency_overrides in tests.
  • Cross-cutting infrastructure (tracing, CORS, compression, timing) is middleware. Per-route policy (auth, tenancy, rate limits) is a dependency.

This guide sits under middleware implementation and answers the question people actually arrive with: they have a piece of cross-cutting logic and two plausible places to put it.

The Problem This Solves

Authentication, rate limiting, request logging, tenant resolution, feature flags — each of these could be written either way, and the two versions look about equally reasonable in a code review. The choice only reveals itself later: when a health check starts requiring an auth token, when a 404 disappears from your access log, when a test cannot stub out the tenant, or when the endpoint needs the value middleware computed and there is no type on it.

The distinction is not stylistic. It follows from where each hook sits in the ASGI stack.

Where middleware and dependencies sit in the ASGI stack Middleware wraps the router and therefore sees unmatched requests. The router dispatches to a route, and only then are dependencies resolved before the endpoint body runs. Middleware stack runs for every request Router no match here means 404 Dependencies matched routes only endpoint body 404 stops here seen by middleware typed value reaches the handler seen by both
Middleware is outside routing; dependencies are inside it. Everything else follows from that.

Why It Happens: Position in the ASGI Chain

A FastAPI application is an ASGI callable wrapped in layers. Middleware you register with add_middleware or @app.middleware("http") wraps the entire application, including the Router object. When a request arrives, every middleware's inbound half runs before the router has looked at the path at all.

Dependencies are resolved by the route handler machinery, after the router has matched a path and a method. FastAPI builds a Dependant tree per route at startup and walks it per request. Nothing in that tree can run for a request that matched no route, because there is no route to own the tree.

Two consequences follow directly:

  • Middleware sees requests that will 404, requests to mounted sub-applications, and OPTIONS preflights that CORSMiddleware answers before any route is involved.
  • Dependencies can participate in the request's typed contract. A dependency's return value is a parameter of the endpoint function, so it is checked by your type checker and documented in OpenAPI. Middleware has no such channel; the best it can do is request.state.something, which is Any forever.

Proving It: One Event Log, Both Hooks

This app registers one middleware and one dependency that append to the same list, then makes requests that exercise the interesting cases — a matched route, a 404, and a mounted sub-application.

"""Middleware sees every request (404s, mounts, static); dependencies see only matched routes."""
from typing import Annotated

from fastapi import Depends, FastAPI, Request

app = FastAPI()

SEEN: list[str] = []


@app.middleware("http")
async def observer(request: Request, call_next):
    response = await call_next(request)
    SEEN.append(f"middleware saw {request.method} {request.url.path} -> {response.status_code}")
    return response


async def route_gate(request: Request) -> str:
    SEEN.append(f"dependency saw {request.method} {request.url.path}")
    return "gated"


@app.get("/matched", dependencies=[Depends(route_gate)])
async def matched():
    return {"ok": True}


@app.get("/typed")
async def typed(gate: Annotated[str, Depends(route_gate)]):
    # A dependency can hand a typed value to the handler. Middleware cannot.
    return {"value_from_dependency": gate}


sub = FastAPI()


@sub.get("/health")
async def sub_health():
    return {"sub": "ok"}


app.mount("/sub", sub)

Real output from the verification run:

$ GET /matched
200 OK
{
  "ok": true
}

$ GET /typed
200 OK
{
  "value_from_dependency": "gated"
}

$ GET /no-such-route
404 Not Found
{
  "detail": "Not Found"
}

$ GET /sub/health
200 OK
{
  "sub": "ok"
}

$ GET /seen
200 OK
{
  "seen": [
    "middleware saw POST /reset -> 200",
    "dependency saw GET /matched",
    "middleware saw GET /matched -> 200",
    "dependency saw GET /typed",
    "middleware saw GET /typed -> 200",
    "middleware saw GET /no-such-route -> 404",
    "middleware saw GET /sub/health -> 200"
  ]
}

Every line matters:

  • For /matched and /typed, both hooks fired, and the dependency fired first — it runs inside the middleware's call_next.
  • For /no-such-route, only the middleware line exists. The 404 never reached a route, so no dependency could run. If your access log or your request-count metric is built on a dependency, every 404 in production is invisible to it.
  • For /sub/health, the outer middleware saw the request even though the route belongs to a mounted sub-application with its own routing table. Dependencies declared on the outer app do not apply there at all.
  • /typed returned "gated" in its body — a value produced by the dependency and received by the handler as a typed parameter. There is no middleware equivalent of that line.

The Decision Table

MiddlewareDependency
Runs for unmatched routes (404)YesNo
Runs for mounted sub-appsYesOnly if declared on that sub-app
Runs for OPTIONS preflightYesNo — answered before routing
Can return a typed value to the handlerNo, only request.stateYes, as a declared parameter
Appears in the OpenAPI schemaNoYes, including security schemes
Replaceable in testsRebuild the appapp.dependency_overrides
Can be scoped to some routesOnly by inspecting the pathNatively, per route or per router
Can short-circuit with a responseYes, by not calling call_nextYes, by raising HTTPException
Can modify the outgoing responseYes — headers, body, statusOnly via the exception path
Sees the response status codeYesNot directly
Has per-request setup and teardownYes, around call_nextYes, with yield
Ordering modelStack; last added is outermostDeclaration order within a route

Read the table as two clusters. The middleware column is about the transport: every byte in and out, regardless of what the application does with it. The dependency column is about the contract: what this particular operation requires in order to run.

Choosing in Practice

Use middleware for: correlation IDs and request tracing, CORS, GZip, access logging, total-request metrics, wall-clock timing, and anything that must also cover 404s and malformed paths. These are properties of the HTTP conversation, not of any endpoint.

Use a dependency for: authentication and authorization, tenant resolution, pagination parameters, per-route rate limits, feature-flag gates, database sessions. These are inputs the endpoint needs, and modelling them as inputs is what keeps them typed, documented and testable.

Use both when context flows one way. The strongest pattern in practice: middleware sets a request ID into a contextvar for every request, and a dependency reads it and returns a typed RequestContext for the routes that want one. Neither layer is doing the other's job.

Authentication deserves the explicit argument, because middleware auth is the single most common misplacement. As middleware it runs on /health, on /docs, and on /openapi.json unless you maintain a path allowlist — and that allowlist is a second source of truth that drifts from your router. It cannot express "this route needs the admin scope". It cannot be overridden per test. And the user it computed arrives in the handler as request.state.user, untyped. As a dependency, all four problems disappear, and router-level attachment described in modular router organization still lets you apply it to a whole group of routes in one line.

Verification

To find out which one your logic currently is, and whether it fires when you expect, add a temporary counter and hit the boundary cases — a 404, an OPTIONS, and your health check:

def test_auth_does_not_gate_health(client):
    assert client.get("/health").status_code == 200      # fails if auth is middleware
    assert client.get("/nope").status_code == 404        # a 404, not a 401


def test_metrics_count_404s(client, metrics):
    client.get("/nope")
    assert metrics.requests_total == 1                   # fails if counting in a dependency

Those two tests encode the whole decision, and they are worth keeping.

Trade-Offs and When Not To

BaseHTTPMiddleware is not free. It runs the downstream app in a separate task and streams the response through a queue, which adds overhead per request and changes when yield dependency teardown is observable, as shown in yield dependencies and cleanup order. For hot paths, pure ASGI middleware avoids that.

Middleware cannot read the parsed body cheaply. Consuming the request stream in middleware to inspect JSON means buffering it and making it re-readable downstream. A dependency gets the validated Pydantic model for free.

Dependencies cannot see the response. If you need the status code or the response size — for logging or metrics — you are in middleware territory, because the dependency has finished long before the response exists.

Exception handlers sit between the two. An exception raised in a dependency is converted to a response by FastAPI's handlers and then travels outward through the middleware stack; an exception raised in middleware is not. That asymmetry is developed in middleware execution order.

FAQ

Does middleware run for requests that 404? Yes. Middleware wraps the router, so it runs before routing has happened and sees every request including ones that match no route at all. A dependency is attached to a route, so a 404 never reaches it.

Can middleware pass a value to my endpoint? Only untyped, through request.state. A dependency returns a real typed value that the endpoint declares in its signature, which the type checker and the OpenAPI schema both understand. That is the strongest argument for using a dependency where you have the choice.

Which is faster, middleware or a dependency? A dependency does less work per request than BaseHTTPMiddleware, which wraps the response in an extra task and stream. For pure ASGI middleware the difference is small. The bigger cost is running logic on every request when only some routes need it.

Should authentication be middleware or a dependency? A dependency, in almost every case. It can return the authenticated user as a typed value, it appears in the OpenAPI security schema, it can be overridden in tests, and it does not run on your health check or your docs route.

Can I use both for the same concern? Yes, and it is often the right shape. Middleware establishes ambient context such as a request ID for every request, and a dependency consumes that context for the routes that need it as a typed value.

Why does my dependency not run for a mounted sub-application? A mount is a separate ASGI application with its own routing table and its own dependency declarations. Outer middleware still wraps it, but outer dependencies belong to the outer app's routes and have nothing to attach to inside the mount.