FastAPI · lesson 17 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6FastAPI Background Tasks and Middleware
FastAPI BackgroundTasks and middleware, measured: the response beats the task, a failing task, request-ID headers, middleware order, and pure ASGI.
By SkillByExample · Published
Work After the Response, and Code Around Every Request
Some work doesn’t belong in the request: sending a welcome email, writing an audit row, resizing an upload. The client shouldn’t wait for it. FastAPI’s BackgroundTasks runs that work after the response has gone out. Middleware is the opposite shape: code that runs around every request, before the route and after it, which is where request IDs and timing headers live.
This lesson measures both: when a background task really runs, what happens when it fails, the order middleware runs in, a header that goes missing on exactly the response you need it on, and where the decorator form of middleware stops being able to see. Outputs come from FastAPI 0.143.0, Starlette 1.7.0 and Uvicorn 0.54.0 on Python 3.12, called with curl 7.85.0. Run each file with uvicorn <file>:app --port 8011 and send requests from a second terminal.
The Response Leaves Before the Task Runs
Add a BackgroundTasks argument to a route and queue functions on it. Save as bg.py:
import time
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
START = time.perf_counter()
def log(msg: str):
print(f"{time.perf_counter() - START:6.2f}s {msg}")
def send_welcome_email(email: str):
log(f"task: sending to {email}")
time.sleep(2) # stands in for a slow SMTP call
log(f"task: sent to {email}")
@app.post("/signup")
def signup(email: str, tasks: BackgroundTasks):
tasks.add_task(send_welcome_email, email)
log("route: returning")
return {"status": "signed up"}
-w makes curl print how long the whole request took:
$ curl -s -w ' %{time_total}s\n' -X POST '127.0.0.1:8011/signup?email=ada@example.com'
{"status":"signed up"} 0.020826s
The server’s terminal (set PYTHONUNBUFFERED=1 so print output appears straight away):
$ uvicorn bg:app --port 8011
INFO: Started server process [3672]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
0.36s route: returning
INFO: 127.0.0.1:58065 - "POST /signup?email=ada@example.com HTTP/1.1" 200 OK
0.36s task: sending to ada@example.com
2.36s task: sent to ada@example.com
The client had its answer in 0.02 seconds. The task started only after the access-log line, and finished 2 seconds later. add_task doesn’t call the function; it stores it with its arguments on the response, and Starlette runs it once the response body has been sent. It’s the same process and the same server, with no queue and no worker: a plain def task runs in the thread pool, like a def route, and an async def task runs on the event loop, where a blocking call would stall every other request.
A Task That Fails
Queue two tasks, and make the first one raise. Save as bg_fail.py:
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def charge_card(order_id: int):
raise ValueError(f"card declined for order {order_id}")
def send_receipt(order_id: int):
print(f"receipt sent for order {order_id}")
@app.post("/orders/{order_id}")
def place_order(order_id: int, tasks: BackgroundTasks):
tasks.add_task(charge_card, order_id)
tasks.add_task(send_receipt, order_id)
return {"order": order_id, "status": "accepted"}
$ curl -s -i -w '\n' -X POST 127.0.0.1:8011/orders/7
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:37:57 GMT
server: uvicorn
content-length: 31
content-type: application/json
{"order":7,"status":"accepted"}
$ curl -s -w '\n' -X POST 127.0.0.1:8011/orders/8
{"order":8,"status":"accepted"}
The traceback in the server terminal is about 40 frames of Uvicorn, Starlette and AnyIO. grep -v '^ ' drops the indented frame lines and keeps the rest:
$ uvicorn bg_fail:app --port 8011 2>&1 | grep -v '^ '
INFO: Started server process [10692]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO: 127.0.0.1:64960 - "POST /orders/7 HTTP/1.1" 200 OK
ERROR: Exception in ASGI application
Traceback (most recent call last):
ValueError: card declined for order 7
INFO: 127.0.0.1:64961 - "POST /orders/8 HTTP/1.1" 200 OK
ERROR: Exception in ASGI application
Traceback (most recent call last):
ValueError: card declined for order 8
The client was told 200 OK and accepted for an order whose card was declined, because the response had already been sent when the task raised. Nothing reaches the client: no 500, no exception handler. Look at what’s missing, too: receipt sent never printed. Tasks on one response run in order, one after another, and an exception stops the rest. The server itself is fine, as order 8 shows. So a background task must handle its own errors, and work that has to happen, such as charging a card, needs a real job queue with retries (Celery, arq, a cloud queue), not BackgroundTasks. Practice task 1 fixes this file.
Middleware: A Timing Header and a Request ID
@app.middleware("http") registers a function that receives every request and a call_next that runs the rest of the app. Save as mw.py:
import time
import uuid
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)
response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
return response
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:8]
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}
$ curl -s -i -w '\n' 127.0.0.1:8011/items/3
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 13
content-type: application/json
x-process-time: 0.0017
x-request-id: 369ae7f0
{"item_id":3}
$ curl -s -i -w '\n' 127.0.0.1:8011/items/3 -H 'X-Request-ID: checkout-42'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 13
content-type: application/json
x-process-time: 0.0008
x-request-id: checkout-42
{"item_id":3}
$ curl -s -i -w '\n' 127.0.0.1:8011/items/abc
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 152
content-type: application/json
x-process-time: 0.0003
x-request-id: dd2d9f52
{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
$ curl -s -i -w '\n' 127.0.0.1:8011/nope
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 22
content-type: application/json
x-process-time: 0.0003
x-request-id: 952a3809
{"detail":"Not Found"}
Both headers are on every response, including the 422 from validation and the 404 for a route that doesn’t exist: middleware wraps the whole app, not individual routes. When the client sends its own X-Request-ID, the middleware passes it through, so one id can follow a request from a load balancer through several services and into every log line. The process time is about a millisecond, since this route does no work. Note the header order: x-process-time was added before x-request-id, so add_timing finished first. That’s a clue to the next section.
The Order Middleware Runs In
Two identical middleware that announce themselves. Save as order.py:
from fastapi import FastAPI, Request
app = FastAPI()
def make_middleware(name: str):
async def middleware(request: Request, call_next):
print(f"{name}: before")
response = await call_next(request)
print(f"{name}: after")
response.headers[f"X-{name}"] = "seen"
return response
return middleware
app.middleware("http")(make_middleware("First"))
app.middleware("http")(make_middleware("Second"))
@app.get("/")
async def root():
print("route")
return {"ok": True}
$ curl -s -i -w '\n' 127.0.0.1:8011/
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:34:25 GMT
server: uvicorn
content-length: 11
content-type: application/json
x-first: seen
x-second: seen
{"ok":true}
$ uvicorn order:app --port 8011
INFO: Started server process [41464]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
Second: before
First: before
route
First: after
Second: after
INFO: 127.0.0.1:58232 - "GET / HTTP/1.1" 200 OK
Second was registered last and runs first. Each new middleware wraps everything registered before it, so the last one added is the outermost layer: first in on the request, last out on the response. That’s why x-first is set before x-second, and why x-process-time came before x-request-id in mw.py. The decorator @app.middleware("http") and app.add_middleware(...) follow the same rule. In practice, register the middleware that must see everything, such as the request ID, last, so its id exists before any other middleware logs.
The 500 That Has No Request ID
A request id is most useful on the response that went wrong. Save as boom.py:
import uuid
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = uuid.uuid4().hex[:8]
print(f"[{request_id}] {request.method} {request.url.path}")
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
@app.get("/boom")
async def boom():
raise RuntimeError("bug in the route")
$ curl -s -i -w '\n' 127.0.0.1:8011/boom
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 11:34:37 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8
Internal Server Error
$ uvicorn boom:app --port 8011 2>&1 | grep -v '^ '
INFO: Started server process [16352]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
[297f037e] GET /boom
INFO: 127.0.0.1:58248 - "GET /boom HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
RuntimeError: bug in the route
No x-request-id. The user reports “I got a 500”, and there’s no id to search the logs for. call_next doesn’t turn the route’s exception into a response: it raises it again inside the middleware, so the header line never runs. The exception keeps going out to Starlette’s ServerErrorMiddleware, which is always the outermost layer, outside every middleware you add, and that’s what sent the plain-text 500. Catch it in the middleware instead. Save as boom_fixed.py:
import logging
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
log = logging.getLogger("uvicorn.error")
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = uuid.uuid4().hex[:8]
try:
response = await call_next(request)
except Exception:
log.exception(f"[{request_id}] unhandled error")
response = JSONResponse({"detail": "Internal error", "request_id": request_id}, 500)
response.headers["X-Request-ID"] = request_id
return response
@app.get("/boom")
async def boom():
raise RuntimeError("bug in the route")
$ curl -s -i -w '\n' 127.0.0.1:8011/boom
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 11:34:47 GMT
server: uvicorn
content-length: 51
content-type: application/json
x-request-id: 3589aadf
{"detail":"Internal error","request_id":"3589aadf"}
$ uvicorn boom_fixed:app --port 8011 2>&1 | grep -v '^ '
INFO: Started server process [40908]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
ERROR: [3589aadf] unhandled error
Traceback (most recent call last):
RuntimeError: bug in the route
INFO: 127.0.0.1:58263 - "GET /boom HTTP/1.1" 500 Internal Server Error
Now the client sees the id in both the header and the body, and the log line carrying the full traceback starts with the same id. log.exception logs at ERROR level with the traceback attached, through Uvicorn’s uvicorn.error logger so it uses the same format. The body says only “Internal error”: never send the exception message to the client, because it can contain SQL, file paths or secrets.
@app.middleware vs Pure ASGI Middleware
The decorator form is built on Starlette’s BaseHTTPMiddleware. It hands you a Request and a Response, which is convenient, but call_next returns as soon as the response’s headers are ready. Time a streaming response with it. Save as http_timing.py:
import asyncio
import time
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.middleware("http")
async def timing(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
print(f"{request.url.path} took {time.perf_counter() - start:.3f}s")
return response
async def three_chunks():
for i in range(3):
await asyncio.sleep(0.5)
yield f"chunk {i}\n"
@app.get("/report")
async def report():
return StreamingResponse(three_chunks(), media_type="text/plain")
$ curl -s -w 'total %{time_total}s\n' 127.0.0.1:8011/report
chunk 0
chunk 1
chunk 2
total 1.558572s
$ uvicorn http_timing:app --port 8011
INFO: Started server process [36244]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
/report took 0.002s
INFO: 127.0.0.1:58299 - "GET /report HTTP/1.1" 200 OK
The client waited 1.56 seconds; the middleware reported 2 milliseconds. The body was streamed after call_next had already returned, so a latency dashboard built on this middleware would call every streaming endpoint instant. A pure ASGI middleware is a class whose __call__ receives the raw scope, receive and send, and it doesn’t return until the app has sent its last message. Save as asgi_timing.py, next to http_timing.py:
import time
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from http_timing import three_chunks
class TimingMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
start = time.perf_counter()
await self.app(scope, receive, send)
print(f"{scope['path']} took {time.perf_counter() - start:.3f}s")
app = FastAPI()
app.add_middleware(TimingMiddleware)
@app.get("/report")
async def report():
return StreamingResponse(three_chunks(), media_type="text/plain")
$ curl -s -w 'total %{time_total}s\n' 127.0.0.1:8011/report
chunk 0
chunk 1
chunk 2
total 1.552849s
$ uvicorn asgi_timing:app --port 8011
INFO: Started server process [39988]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO: 127.0.0.1:58307 - "GET /report HTTP/1.1" 200 OK
/report took 1.535s
1.535 seconds, matching what the client saw. The line now prints after Uvicorn’s access log, because the access log is written when the response starts and this middleware waits for the end. The scope["type"] check matters: the same class also receives lifespan and websocket scopes, which the decorator form never sees, and this one passes them straight through.
@app.middleware("http") | Pure ASGI class | |
|---|---|---|
| You work with | Request, Response | scope, receive, send messages |
| Sees the streamed body | No, call_next returns at the headers | Yes, every send message |
| WebSocket and lifespan | No | Yes, by scope["type"] |
| Adding a header | response.headers[...] = ... | Wrap send and edit http.response.start (Practice 4) |
| Use it for | Short header and logging work | Streaming, timing, anything performance-sensitive |
Practice
- Fix
bg_fail.py: one background task should charge the card and then send the receipt, catch the payment error, log it with its traceback through theuvicorn.errorlogger, and skip the receipt. For a runnable demo, decline odd order numbers.
Solution
Save as p1.py:
import logging
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
log = logging.getLogger("uvicorn.error")
def process_order(order_id: int):
try:
if order_id % 2:
raise ValueError(f"card declined for order {order_id}")
print(f"charged order {order_id}")
except ValueError:
log.exception(f"order {order_id}: payment failed, no receipt sent")
return
print(f"receipt sent for order {order_id}")
@app.post("/orders/{order_id}")
def place_order(order_id: int, tasks: BackgroundTasks):
tasks.add_task(process_order, order_id)
return {"order": order_id, "status": "accepted"}
$ curl -s -w '\n' -X POST 127.0.0.1:8011/orders/7
{"order":7,"status":"accepted"}
$ curl -s -w '\n' -X POST 127.0.0.1:8011/orders/8
{"order":8,"status":"accepted"}
$ uvicorn p1:app --port 8011 2>&1 | grep -v '^ '
INFO: Started server process [6128]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO: 127.0.0.1:58327 - "POST /orders/7 HTTP/1.1" 200 OK
ERROR: order 7: payment failed, no receipt sent
Traceback (most recent call last):
ValueError: card declined for order 7
INFO: 127.0.0.1:58328 - "POST /orders/8 HTTP/1.1" 200 OK
charged order 8
receipt sent for order 8
The error is now a log line that names the order, not an anonymous “Exception in ASGI application”. The receipt depends on the charge, so they belong in one task, where the order between them is explicit. The client still sees accepted for order 7: a background task can’t change a response that’s already sent. If the client must know whether the charge worked, do it in the route, or return a job id it can poll.
- Write a middleware that returns 401 without calling the route unless the request has
X-API-Key: s3cret. Check/reportswith and without the key, then check/docs. What does that tell you about middleware versus a dependency for auth?
Solution
Save as p2.py:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.middleware("http")
async def require_api_key(request: Request, call_next):
if request.headers.get("X-API-Key") != "s3cret":
return JSONResponse({"detail": "missing or wrong API key"}, status_code=401)
return await call_next(request)
@app.get("/reports")
async def reports():
print("route ran")
return {"reports": 3}
$ curl -s -w ' %{http_code}\n' 127.0.0.1:8011/reports
{"detail":"missing or wrong API key"} 401
$ curl -s -w ' %{http_code}\n' 127.0.0.1:8011/reports -H 'X-API-Key: s3cret'
{"reports":3} 200
$ curl -s -w ' %{http_code}\n' 127.0.0.1:8011/docs -o /dev/null
401
$ uvicorn p2:app --port 8011
INFO: Started server process [25436]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO: 127.0.0.1:58339 - "GET /reports HTTP/1.1" 401 Unauthorized
route ran
INFO: 127.0.0.1:58340 - "GET /reports HTTP/1.1" 200 OK
INFO: 127.0.0.1:58341 - "GET /docs HTTP/1.1" 401 Unauthorized
Returning a response without awaiting call_next short-circuits the request: route ran printed only once. But middleware applies to everything, so /docs, /openapi.json and any health check are locked too, and the 401 doesn’t appear in the OpenAPI schema. That’s why auth is usually a dependency, attached to the routes or routers that need it. Middleware suits rules that genuinely cover every request.
- Test
bg.pywithTestClient: one test should replacesend_welcome_emailwithmonkeypatchand check it was queued with the right address, and a second should measure how longclient.posttakes with the real task. Run pytest with-sto see the prints.
Solution
Save as test_bg.py next to bg.py:
import time
from fastapi.testclient import TestClient
import bg
client = TestClient(bg.app)
def test_signup_sends_email(monkeypatch):
sent = []
monkeypatch.setattr(bg, "send_welcome_email", sent.append)
r = client.post("/signup", params={"email": "ada@example.com"})
assert r.json() == {"status": "signed up"}
assert sent == ["ada@example.com"]
def test_real_task_runs_before_the_client_returns():
start = time.perf_counter()
client.post("/signup", params={"email": "bob@example.com"})
assert time.perf_counter() - start > 2
$ pytest -v --no-header -s test_bg.py
============================= test session starts =============================
collecting ... collected 2 items
test_bg.py::test_signup_sends_email 0.02s route: returning
PASSED
test_bg.py::test_real_task_runs_before_the_client_returns 0.03s route: returning
0.03s task: sending to bob@example.com
2.03s task: sent to bob@example.com
PASSED
============================== 2 passed in 2.29s ==============================
Unlike curl, TestClient waits for the background tasks before post returns, which is what makes sent == [...] checkable straight after the call. The cost is the second test: it took the full 2 seconds of the fake email. monkeypatch.setattr works because the route looks send_welcome_email up in the module when it runs, and pytest puts the original back after the test. Patch out slow or external tasks in tests and assert on what was queued.
- Rewrite the request-ID middleware as a pure ASGI class that adds the header by wrapping
send. Try it on a route that returns100 / n, withn=4andn=0. Does the pure ASGI version fix the missing header on a 500?
Solution
Save as p4.py:
import uuid
from fastapi import FastAPI
class RequestIDMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
request_id = uuid.uuid4().hex[:8].encode()
async def send_with_id(message):
if message["type"] == "http.response.start":
message["headers"] = [*message["headers"], (b"x-request-id", request_id)]
await send(message)
await self.app(scope, receive, send_with_id)
app = FastAPI()
app.add_middleware(RequestIDMiddleware)
@app.get("/divide/{n}")
async def divide(n: int):
return {"result": 100 / n}
$ curl -s -i -w '\n' 127.0.0.1:8011/divide/4
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:36:08 GMT
server: uvicorn
content-length: 15
content-type: application/json
x-request-id: 7888f54b
{"result":25.0}
$ curl -s -i -w '\n' 127.0.0.1:8011/divide/0
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 11:36:08 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8
Internal Server Error
It doesn’t. On the 500 the route raised before sending anything, so no http.response.start passed through send_with_id. The exception went on out to ServerErrorMiddleware, which sits outside this class and sent its own response with the original send. Headers in ASGI are a list of (bytes, bytes) pairs in the http.response.start message, which is why the id is encoded. The type check is left out here because lifespan and websocket scopes never send http.response.start, so the wrapper passes their messages through untouched. To cover 500s, add a try/except around await self.app(...) that sends its own response with the header, as boom_fixed.py does.