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_overridesin 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.
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
OPTIONSpreflights 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 isAnyforever.
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
/matchedand/typed, both hooks fired, and the dependency fired first — it runs inside the middleware'scall_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. /typedreturned"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
| Middleware | Dependency | |
|---|---|---|
| Runs for unmatched routes (404) | Yes | No |
| Runs for mounted sub-apps | Yes | Only if declared on that sub-app |
Runs for OPTIONS preflight | Yes | No — answered before routing |
| Can return a typed value to the handler | No, only request.state | Yes, as a declared parameter |
| Appears in the OpenAPI schema | No | Yes, including security schemes |
| Replaceable in tests | Rebuild the app | app.dependency_overrides |
| Can be scoped to some routes | Only by inspecting the path | Natively, per route or per router |
| Can short-circuit with a response | Yes, by not calling call_next | Yes, by raising HTTPException |
| Can modify the outgoing response | Yes — headers, body, status | Only via the exception path |
| Sees the response status code | Yes | Not directly |
| Has per-request setup and teardown | Yes, around call_next | Yes, with yield |
| Ordering model | Stack; last added is outermost | Declaration 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.
Related Reading
- Up to the section: Middleware Implementation.
- When both layers are in play: Middleware Execution Order for how the stack unwinds and where exception handlers sit.
- The canonical middleware job: Implementing Custom Middleware for Request Tracing.
- A middleware you should not write yourself: CORS Middleware Configuration.
- The dependency side: Dependency Injection Strategies and Overriding Dependencies in Tests.