šÆ What You Will Learn
Middleware is code that wraps every request ā the perfect place for cross-cutting concerns like timing, request IDs, and logging that don't belong in any single endpoint.
- The request lifecycle and where middleware sits
- Your first middleware: a response-timing header
- Request IDs via
request.state - Class-based middleware with
BaseHTTPMiddleware - Middleware ordering (it's an onion, and order matters)
- Middleware vs dependencies ā when to use which
Prerequisites: a basic FastAPI app and familiarity with Depends() (fastapi-dependency-injection), since we'll compare the two.
fastapi-middleware/
āāā requirements.txt
āāā app/
āāā __init__.py
āāā main.py # app + middleware registration
āāā middleware.py # custom middlewareThe Mental Model: An Onion Around Every Request
Middleware wraps your endpoints like layers of an onion. Every request passes down through each layer on the way in, hits your route, then passes back up through the same layers on the way out. You get a hook before and after the endpoint runs.
āāāāāāāāāāāāāāāā Middleware A āāāāāāāāāāāāāāāā
ā āāāāāāāāāāā Middleware B āāāāāāāāāāā ā
in ā ā ā ā ā [ your endpoint ] ā ā ā ā ā out
ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Before endpoint: A ā B (top to bottom)
After endpoint: B ā A (bottom back to top)The Problem: Cross-Cutting Code Everywhere (See It First)
Say you want to log how long each request takes. Without middleware, you'd paste the same code into every route:
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/users")
def list_users():
start = time.perf_counter() # copy...
result = {"users": []}
print(f"took {time.perf_counter() - start:.4f}s") # ...paste
return result
@app.get("/orders")
def list_orders():
start = time.perf_counter() # again š©
result = {"orders": []}
print(f"took {time.perf_counter() - start:.4f}s")
return resultYour First Middleware: a Timing Header
The simplest form is a function decorated with @app.middleware("http"). It receives the request and a call_next function; you do work before/after calling it.
import time
from fastapi import FastAPI, Request
app = FastAPI(title="FastAPI Middleware", version="0.1.0")
@app.middleware("http")
async def add_timing_header(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request) # run the rest (other middleware + endpoint)
elapsed = time.perf_counter() - start
response.headers["X-Process-Time"] = f"{elapsed:.4f}"
return response
@app.get("/users")
def list_users():
return {"users": []}call_next(request) is the "go deeper into the onion" call ā it runs the remaining middleware and your endpoint, and returns the response so you can modify it on the way out.
X-Process-Time header. Add a hundred endpoints ā they're all timed automatically, and no handler knows about it.Request IDs with request.state
A request ID lets you trace a single request across all your logs. Generate one in middleware, stash it on request.state (readable by endpoints and dependencies), and echo it back in a header.
import uuid
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# reuse an incoming id (from a proxy) or generate a new one
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id # available to routes/deps
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return responsefrom app.middleware import RequestIDMiddleware
app.add_middleware(RequestIDMiddleware)
@app.get("/whoami")
def whoami(request: Request):
# anything downstream can read the id off request.state
return {"request_id": request.state.request_id}request.state is a per-request scratchpad. Middleware writes to it; endpoints and dependencies read from it. It's the standard way to pass computed context (request id, current tenant) down the stack.Class-Based Middleware (BaseHTTPMiddleware)
The @app.middleware decorator is fine for one-offs, but class-based middleware (subclassing BaseHTTPMiddleware) is reusable and configurable ā you can pass options in its constructor. Here's a logging middleware that uses the request id from the previous step:
import logging
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("app.access")
class AccessLogMiddleware(BaseHTTPMiddleware):
def __init__(self, app, *, slow_ms: int = 500) -> None:
super().__init__(app)
self.slow_ms = slow_ms # configurable threshold
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
rid = getattr(request.state, "request_id", "-")
level = logging.WARNING if elapsed_ms > self.slow_ms else logging.INFO
logger.log(
level,
"%s %s -> %s (%.1fms) rid=%s",
request.method, request.url.path, response.status_code, elapsed_ms, rid,
)
return responsefrom app.middleware import AccessLogMiddleware, RequestIDMiddleware
# configurable: warn on requests slower than 300ms
app.add_middleware(AccessLogMiddleware, slow_ms=300)
app.add_middleware(RequestIDMiddleware)AccessLogMiddleware into any project and tune slow_ms per app ā no code changes, just a constructor argument.Middleware Ordering (Important!)
Order is where people get burned. With add_middleware, the last one added is the outermost ā it runs first on the way in and last on the way out. It's a stack (LIFO).
app.add_middleware(AccessLogMiddleware, slow_ms=300) # added 1st -> INNER
app.add_middleware(RequestIDMiddleware) # added 2nd -> OUTERRequest flow with the code above: in ā RequestIDMiddleware ā AccessLogMiddleware ā endpoint out ā RequestIDMiddleware ā AccessLogMiddleware ā endpoint RequestID is OUTER, so request.state.request_id exists before AccessLog reads it. Reverse the order and the log shows rid=- because the id isn't set yet.
RequestIDMiddleware must be outer, so it's added last.CORSMiddleware is usually added last so it's outermost and can handle preflight OPTIONS before anything else runs.Middleware vs Dependencies
Both run "around" your endpoint, so which do you reach for? The rule: middleware for things that apply to every request and work at the raw request/response level; dependencies for per-route logic that returns a value your handler uses.
| Use case | Middleware | Dependency |
|---|---|---|
| Applies to every request | ā | ā (per-route) |
| Needs to return a value to the handler | ā | ā |
| Modify the outgoing response (headers) | ā | ā |
| Auth check for specific routes | ā ļø possible but clumsy | ā ideal |
| Timing / request-id / access logs | ā | ā |
| Load current user / DB session | ā | ā |
Try It (curl)
fastapi==0.116.1
uvicorn[standard]==0.30.6python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload$ curl -s -D - http://127.0.0.1:8000/whoami -o /dev/null HTTP/1.1 200 OK x-process-time: 0.0004 x-request-id: 5f9c8b1a-... # generated for us # pass your own request id -> it's reused, not replaced $ curl -s -D - http://127.0.0.1:8000/whoami -H "X-Request-ID: trace-123" -o /dev/null x-request-id: trace-123 # server log from AccessLogMiddleware: # INFO GET /whoami -> 200 (0.4ms) rid=trace-123
Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
Forgetting await call_next(request) | Request hangs or errors | Always return await call_next(request)'s response |
| Wrong middleware order | Reads data another middleware hasn't set yet | Remember: last added = outermost (LIFO) |
| Heavy/blocking work in middleware | Every single request slows down | Keep it light; offload heavy work elsewhere |
| Using middleware for per-route auth | Awkward path matching in middleware | Use a dependency on those routes instead |
| Reading the request body in middleware | Endpoint then sees an empty body | Avoid consuming the body; if needed, re-inject it carefully |
| Swallowing exceptions in middleware | Errors vanish, no error response | Let exceptions propagate to exception handlers |
What's Next
fastapi-loggingā turn the access log into structured, searchable JSON logsfastapi-dependency-injectionā the per-route counterpart to middlewarefastapi-corsā the most common built-in middleware, in depthfastapi-error-handlingā where exceptions go after passing through middleware
Recap: middleware wraps every request like an onion ā hook before and after via call_next. Use it for cross-cutting concerns (timing, request ids, logging), prefer class-based middleware for reuse, mind the LIFO ordering, and reach for dependencies when you need a value in your handler.