Module 03: Middleware Fundamentals
Why this matters
So far every piece of logic you've written lived inside a specific handler: validate this body, normalize that email. But some concerns aren't about one endpoint — they're about every request. You want to log every request that comes in. You want to attach a request ID to every response. You want to reject unauthenticated requests before they reach any protected handler. You want to add security headers to every response leaving the building. If you copy that logic into all forty of your route handlers, you have a maintenance nightmare and, worse, a security nightmare — the one endpoint where you forgot to paste the auth check is the one that gets breached.
Middleware is the answer. It's code that wraps the request/response cycle: it runs before your handler sees the request, and again after your handler produces the response, on the way back out. Think of it as a series of airport checkpoints every passenger passes through — security screening, passport control, boarding-pass scan — regardless of which gate they're ultimately headed to. Each checkpoint can inspect you, stamp your documents, send you back (short-circuit), or wave you through to the next one. Your handler is the gate at the end; middleware is everything you pass through before and after.
The mental model that unlocks middleware is the onion (or the nesting doll). The request travels inward through each layer to reach the handler at the core; the response travels outward through the same layers in reverse. A single middleware sees the request on the way in and the response on the way out — it's wrapped around everything deeper than it. This is why order matters enormously: the middleware you register first is the outermost layer, the first to touch the request and the last to touch the response. Get the order wrong and you'll authenticate after you've already logged the request as anonymous, or compress a response before adding the header that says it's compressed.
Concepts
What middleware is and where it sits
Middleware is a function that receives the incoming request and a reference to "the next thing in the chain," does some work, calls the next thing, and then gets a chance to work on the response before returning it. In Starlette/FastAPI, the HTTP-middleware signature is:
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def my_middleware(request: Request, call_next):
# --- pre-request phase: runs BEFORE the handler ---
response = await call_next(request) # hands off to the next layer / handler
# --- post-response phase: runs AFTER the handler ---
return response # response travels back outward
call_next is the crucial piece. It represents "everything deeper in the
onion" — the remaining middleware and, eventually, your route handler.
Calling await call_next(request) passes control inward and returns the
response that bubbles back out. Everything before that line is pre-request;
everything after is post-response.
The two phases: pre-request and post-response
A single middleware straddles two moments in time:
- Pre-request (before
call_next): inspect/modify the incoming request, start a timer, extract an auth token, generate a request ID, reject early. - Post-response (after
call_next): inspect/modify the outgoing response, add headers, log the status code and elapsed time, record metrics.
import time
@app.middleware("http")
async def timing(request: Request, call_next):
start = time.perf_counter() # pre-request
response = await call_next(request) # run everything inside
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Process-Time-ms"] = f"{elapsed_ms:.1f}" # post-response
return response
One function, both sides of the handler. That's the defining feature.
async def timing(request, call_next):
┌───────────────────────────────────────────┐
│ start = ... ◄── PRE-REQUEST │ (before call_next)
│ response = await call_next(request) ───────┼──► inner layers + handler
│ response.headers[...] = ... ◄── POST-RESP │ (after call_next)
│ return response │
└───────────────────────────────────────────┘
call_next is the boundary between the two phases
The chain and execution order (the onion)
Register three middlewares and you get three nested layers. With FastAPI's
@app.middleware("http") (and app.add_middleware), the last one added is
the outermost wrapper — but the mental model to hold is simply "each
middleware wraps everything registered before it becomes inner." Rather
than memorize the registration-direction rule, reason about the onion: on
the way in, requests pass outer → inner; on the way out, responses pass
inner → outer.
request ─► [ Logging ] ─► [ Auth ] ─► [ Validate ] ─► Handler
response ◄─ [ Logging ] ◄─ [ Auth ] ◄─ [ Validate ] ◄─ Handler
Here Logging is outermost: it sees the request first (so it can log the true arrival time and the eventual final status) and the response last. Auth runs after logging but before the handler — so an unauthorized request is logged, then rejected, and never reaches Validate or the Handler. This ordering is deliberate and you design it; it's the whole subject of module 04.
call_next, short-circuiting, and never reaching the handler
A middleware doesn't have to call call_next. If it returns a response
without calling it, it short-circuits the pipeline — the handler and
every inner layer never run. This is how you cheaply reject bad requests at
the edge:
from fastapi.responses import JSONResponse
@app.middleware("http")
async def block_legacy_paths(request: Request, call_next):
if request.url.path.startswith("/v0/"):
# Return immediately; the handler is never invoked.
return JSONResponse(status_code=410, content={"error": "API v0 is retired"})
return await call_next(request) # only reached for non-/v0 paths
normal: req ─► [ gate ] ─► [ inner ] ─► Handler ─► resp
short-circuit:
req ─► [ gate ]──✗ return 410 Handler + inner
│ NEVER run
└──► resp travels straight back out
Short-circuiting is a feature, not an escape hatch: auth middleware
short-circuits with a 401, rate limiters with a 429, a maintenance-mode
gate with a 503. The pattern is always "decide in the pre-request phase;
either return early or call call_next."
Handling 404s and cross-cutting concerns early
Some decisions are best made before routing even resolves. A request for a
path that matches no route will, by default, fall through to FastAPI's 404
handler at the core of the onion — but middleware still wraps it, so your
logging middleware does see and log that 404, and your security-headers
middleware does still add headers to it. That's exactly what you want:
cross-cutting concerns apply uniformly, including to error responses. You
generally don't short-circuit 404s in middleware yourself (let the router
produce them), but you rely on middleware wrapping them so logging and headers
are consistent even for not-found and error responses.
Middleware vs. dependencies (a FastAPI-specific distinction)
FastAPI has a second mechanism that overlaps with middleware:
dependencies (Depends(...), covered in module 05). Rough rule of thumb:
use middleware for concerns that apply to every request uniformly and
operate on the raw request/response (logging, headers, compression, request
IDs, global timing). Use dependencies for concerns scoped to specific
endpoints and that need FastAPI's parsing/validation/injection (auth for a
group of routes, loading the current user, enforcing per-route permissions).
They're complementary; module 05 shows where each shines. For now, know that
"runs for everything, wraps request+response" ⇒ middleware.
Command reference
| Pattern | What it does | Example |
|---|---|---|
@app.middleware("http") |
Registers an HTTP middleware function | async def m(request, call_next): |
await call_next(request) |
Passes control inward; returns the response | response = await call_next(request) |
return before call_next |
Short-circuits — handler never runs | return JSONResponse(...) |
response.headers["X"] = "y" |
Post-response header mutation | response.headers["X-Request-ID"] = rid |
request.state.x = y |
Stash per-request data for inner layers/handlers | request.state.request_id = rid |
app.add_middleware(Cls, **opts) |
Registers a class-based / built-in middleware | app.add_middleware(GZipMiddleware) |
request.url.path |
The path being requested (for routing decisions) | if path.startswith("/admin") |
request.method |
The HTTP method | if request.method == "OPTIONS" |
Two ways to register middleware. @app.middleware("http") decorates a
function — convenient for custom logic. app.add_middleware(SomeMiddleware, option=...) registers a class-based middleware — used for built-ins
(GZipMiddleware, CORSMiddleware, TrustedHostMiddleware) and reusable
components. Both participate in the same onion. Ordering caveat: with
add_middleware, the middleware added last ends up outermost. When you
mix the decorator and add_middleware, keep them straight by testing the
observed order (exercise 3), not by guessing.
request.state is the hand-off channel. A middleware computes something
in the pre-request phase (a request ID, the authenticated user) and stashes
it on request.state so inner layers and the handler can read it. This is
the seed of "request context," the entire subject of module 05.
Middleware runs for every route, including unmatched ones. Because
middleware wraps routing itself, it also wraps the 404 for unknown paths
and the 500 for handler errors — which is why it's the right place for
logging and headers that must be truly universal.
Hands-on exercises
Continue in the api-layer project.
1. Your first middleware: timing header
import time
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_timing(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed = (time.perf_counter() - start) * 1000
response.headers["X-Process-Time-ms"] = f"{elapsed:.2f}"
return response
@app.get("/hello")
async def hello():
return {"msg": "hi"}
Call GET /hello and inspect the response headers (curl -i). Expected: an
X-Process-Time-ms header on the response, even though hello knows nothing
about timing. That's a cross-cutting concern applied without touching the
handler.
2. Prove both phases run around the handler
Add print statements to see the onion:
@app.middleware("http")
async def trace(request: Request, call_next):
print(f"[trace] BEFORE handler: {request.method} {request.url.path}")
response = await call_next(request)
print(f"[trace] AFTER handler: status {response.status_code}")
return response
Call any endpoint and watch the server console. Expected: BEFORE prints,
then your handler runs, then AFTER prints with the status — one function
straddling both sides.
3. Observe execution order with two middlewares
@app.middleware("http")
async def outer(request: Request, call_next):
print("[outer] in")
resp = await call_next(request)
print("[outer] out")
return resp
@app.middleware("http")
async def inner(request: Request, call_next):
print("[inner] in")
resp = await call_next(request)
print("[inner] out")
return resp
Call an endpoint. Expected order in the console (note the symmetry — the onion unwinds in reverse):
[inner] in (registered last -> outermost -> touches request first)
[outer] in
...handler...
[outer] out
[inner] out (outermost -> touches response last)
Write down which registration ends up outermost. This surprises everyone once; verify it empirically rather than trusting your memory.
4. Short-circuit: retire an old API version
from fastapi.responses import JSONResponse
@app.middleware("http")
async def block_v0(request: Request, call_next):
if request.url.path.startswith("/v0/"):
return JSONResponse(status_code=410, content={"error": "API v0 is retired"})
return await call_next(request)
@app.get("/v0/legacy")
async def legacy():
return {"msg": "you should never see this"}
Call GET /v0/legacy. Expected: 410, and the legacy handler's body never
appears — confirm by adding a print inside legacy that does not fire. The
pipeline was short-circuited before reaching the handler.
5. Attach a request ID and hand it inward
import uuid
@app.middleware("http")
async def request_id(request: Request, call_next):
rid = request.headers.get("X-Request-ID") or str(uuid.uuid4())
request.state.request_id = rid # stash for inner layers/handler
response = await call_next(request)
response.headers["X-Request-ID"] = rid # echo it back to the client
return response
@app.get("/whoami")
async def whoami(request: Request):
return {"request_id": request.state.request_id}
Call GET /whoami. Expected: the JSON body and the X-Request-ID response
header carry the same id. Send your own X-Request-ID header and confirm
it's respected. This is the first taste of request context (module 05).
6. Confirm middleware wraps 404s
With the trace middleware from exercise 2 still active, call a nonexistent
path like GET /does-not-exist. Expected: the trace middleware's BEFORE
and AFTER prints still fire, and AFTER reports status 404. Middleware
wraps routing itself, so even unmatched paths pass through your cross-cutting
logic.
7. A short-circuit maintenance gate
MAINTENANCE = True
@app.middleware("http")
async def maintenance_gate(request: Request, call_next):
if MAINTENANCE and request.url.path != "/health":
return JSONResponse(status_code=503, content={"error": "under maintenance"})
return await call_next(request)
@app.get("/health")
async def health():
return {"status": "ok"}
Call /health (allowed through) and any other path (503). Expected:
/health returns 200 even during maintenance; everything else is
short-circuited with 503. Flip MAINTENANCE = False and confirm normal
service resumes.
8. Diagnose and fix
This middleware is supposed to add an X-App-Version header to every
response and log timing. Instead, every request hangs forever (or errors),
and no header appears. Find and fix two bugs.
import time
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def broken(request: Request, call_next):
start = time.perf_counter()
response = call_next(request) # (A)
elapsed = (time.perf_counter() - start) * 1000
response.headers["X-App-Version"] = "1.0"
print(f"took {elapsed:.2f}ms")
# (B) no return
@app.get("/ping")
async def ping():
return {"pong": True}
Solution
response = call_next(request)is missingawait.call_nextis a coroutine; withoutawaityou get a coroutine object, not a response, andresponse.headers[...]fails (or you never actually run the handler). Must beresponse = await call_next(request).- The middleware never returns the response. Falling off the end returns
None, so the framework has nothing to send — the request hangs/errors. Addreturn responseat the end.
@app.middleware("http")
async def fixed(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed = (time.perf_counter() - start) * 1000
response.headers["X-App-Version"] = "1.0"
print(f"took {elapsed:.2f}ms")
return response
The two mistakes — forgetting await and forgetting return — are the most
common middleware bugs there are. Burn them in now.
Independent challenge
No code given. Add a middleware that enforces a global maximum request body
size: any request whose Content-Length header exceeds 1 MB is rejected
immediately with a 413 Payload Too Large, before the body is read or any
handler runs — using the short-circuit technique from this module. Then
add a second middleware that logs, for every request, the method, path, final
status code, and elapsed time. Order the two deliberately so that a rejected
oversized request is still logged with its 413 status, and explain (in a
comment) why the logging middleware has to be the outer layer for that to
work. Reach back to module 00's fail-fast principle and note how
rejecting at the middleware edge is the earliest possible fail-fast point.
Hint
Read request.headers.get("content-length"), parse it to an int (guard
against it being missing), and return JSONResponse(status_code=413, ...)
without calling call_next. For the logging middleware to see the 413, it
must run around the size gate — i.e. it must be the outer layer, so the
size gate's short-circuit response still travels back out through it. Recall
from exercise 3 that the middleware registered last becomes outermost.
Common mistakes & troubleshooting
- Forgetting
awaitbeforecall_next. You get a coroutine, not a response, and everything downstream breaks. Alwaysawait call_next(...). - Forgetting to
return response. A middleware that doesn't return the response leaves the framework with nothing to send — hangs or errors. - Guessing the execution order. Registration direction is easy to get
backwards. Verify empirically (print
in/out) and design order deliberately (module 04). - Doing heavy work in middleware that only some routes need. Middleware runs for every request. Route-specific concerns belong in dependencies (module 05), not global middleware.
- Mutating the request body in middleware. Reading/replacing the body stream in Starlette middleware is fragile (the stream can only be consumed once). Prefer dependencies or careful body-parsing middleware (module 04) for body work.
- Assuming short-circuit responses skip other middleware. A short-circuit return still travels outward through every outer middleware — that's why logging (if outer) still sees a rejected request.
- Putting business logic in middleware. Middleware is for cross-cutting concerns. Endpoint-specific logic belongs in handlers/services (module 06).
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What single line inside a middleware separates the pre-request phase from the post-response phase, and what does it represent?
- Draw (in words) the onion for three middlewares Logging → Auth → Validate wrapping a handler, and say which sees the request first and which sees the response last.
- How does a middleware short-circuit the pipeline, and name two real cases where you'd want to?
- If a request hits a path that matches no route, does your logging middleware still run? Why or why not?
- What are the two most common bugs in a hand-written middleware, and what symptom does each produce?
- When would you reach for middleware versus a FastAPI dependency for a cross-cutting concern?
Answers
response = await call_next(request). It represents handing control to everything deeper in the onion (inner middleware + the handler); everything before it is pre-request, everything after is post-response.- Logging is outermost, then Auth, then Validate, then the handler at the core. On the way in, the request passes Logging → Auth → Validate → handler; on the way out the response passes handler → Validate → Auth → Logging. Logging sees the request first and the response last.
- By returning a response without calling
call_next, so the handler and inner layers never run. Cases: auth failure (401), rate limit (429), maintenance mode (503), oversized payload (413), retired API version (410). - Yes — middleware wraps routing itself, so it also wraps the
404produced for an unmatched path. The logging middleware's post-response phase sees and logs that404. - Forgetting
awaitbeforecall_next(you get a coroutine instead of a response; downstream access fails), and forgettingreturn response(the framework has nothing to send; the request hangs or errors). - Middleware for concerns that apply to every request and work on the raw request/response (logging, headers, compression, request IDs). Dependencies for concerns scoped to specific endpoints that need FastAPI's parsing and injection (per-route auth, loading the current user, permission checks).
Further reading & sources
- FastAPI — Middleware - the official guide to
@app.middleware("http"),call_next, and the pre/post-response phases. - FastAPI — Advanced Middleware -
app.add_middleware, built-in middleware classes, and how ordering works. - Starlette — Middleware - the underlying ASGI middleware model FastAPI builds on, including the onion execution order.
- MDN — 503 Service Unavailable - the status a maintenance-gate middleware short-circuits with.
- MDN — 410 Gone - the correct status for a retired API path returned by a short-circuiting middleware.
Next
04-common-middleware-patterns — now that you understand the pipeline, you'll assemble the standard cast of production middleware (security headers, CORS, rate limiting, auth, logging, error handling, compression) and, crucially, order them correctly.