In this post
What this post covers
Experience is mostly a list of mistakes you no longer make. This part collects thirty that FastAPI teams have paid for in production, grouped by area, each with the symptom you would actually see and the fix. Nine of the least obvious are demonstrated below on the installed FastAPI, because several of them fail silently. Use the list as a code-review and pre-release checklist.
A pilot's checklist
Pilots with twenty years of experience still read the checklist before every take-off, because the dangerous mistakes are not the ones you don't know about; they are the ones you know perfectly well and forget once, at the worst moment. A checklist turns experience into a habit anyone on the team can follow.
1. Nine silent surprises, demonstrated
each line is a pitfall from the tables below (number in front)
import warnings
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated
from fastapi import Depends, FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
# 25: an on_event handler next to a lifespan never runs
ran = []
@asynccontextmanager
async def lifespan(app):
ran.append("lifespan")
yield
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
legacy_app = FastAPI(lifespan=lifespan)
@legacy_app.on_event("startup")
async def warm_cache():
ran.append("on_event: warm_cache")
with TestClient(legacy_app):
pass
print(f"#25 startup handlers that ran: {ran} (warnings: {len(caught)} DeprecationWarning)")
app = FastAPI()
class UserOut(BaseModel):
id: int
email: str
USER = {"id": 1, "email": "[email protected]", "password_hash": "$2b$12$Qx…"}
@app.get("/typed")
def typed() -> UserOut:
return USER
@app.get("/manual")
def manual() -> UserOut:
return JSONResponse(USER) # 12: bypasses the return type entirely
@app.get("/search")
def search(tags: list[str] = []): # 14: without Query() this is a *body* field
return {"tags": tags}
@app.get("/search-fixed")
def search_fixed(tags: Annotated[list[str], Query()] = []):
return {"tags": tags}
@app.get("/users/{user_id}")
def get_user(user_id: int):
return {"id": user_id}
@app.get("/users/me") # 15: declared too late, never reached
def get_me():
return {"me": True}
@app.post("/orders/")
def create_order():
return {"created": True}
@app.get("/greet")
def greet(name: str | None): # 17: "can be None" is not "optional"
return {"hello": name}
@app.get("/when")
def when():
return {"naive": datetime(2026, 9, 19, 10, 0), # 18: which timezone is this?
"aware": datetime.fromisoformat("2026-09-19T10:00:00+05:30")}
counter = []
def new_request_number() -> int:
counter.append(1)
return len(counter)
@app.get("/numbers")
def numbers(a: Annotated[int, Depends(new_request_number)],
b: Annotated[int, Depends(new_request_number)], # 19: cached, same value as a
c: Annotated[int, Depends(new_request_number, use_cache=False)]):
return [a, b, c]
class Item(BaseModel):
name: str
@app.post("/items")
def add_item(item: Item):
return item
c = TestClient(app)
print("#12 typed return: ", c.get("/typed").json())
print(" JSONResponse: ", c.get("/manual").json())
print("#14 ?tags=a&tags=b ->", c.get("/search?tags=a&tags=b").json(), " with Query():", c.get("/search-fixed?tags=a&tags=b").json())
r = c.get("/users/me")
print("#15 GET /users/me ->", r.status_code, r.json()["detail"][0]["msg"])
r = c.post("/orders", follow_redirects=False)
print("#16 POST /orders ->", r.status_code, "redirect to", r.headers["location"])
r = c.get("/greet")
print("#17 GET /greet ->", r.status_code, r.json()["detail"][0]["msg"])
print("#18 datetimes ->", c.get("/when").json())
print("#19 three Depends ->", c.get("/numbers").json())
r = c.post("/items", content='{"name": "tea"}', headers={"Content-Type": "text/plain"})
print("#27 JSON sent as text/plain ->", r.status_code, r.json()["detail"][0]["msg"][:48] + "…")
Output
#25 startup handlers that ran: ['lifespan'] (warnings: 2 DeprecationWarning)
#12 typed return: {'id': 1, 'email': '[email protected]'}
JSONResponse: {'id': 1, 'email': '[email protected]', 'password_hash': '$2b$12$Qx…'}
#14 ?tags=a&tags=b -> {'tags': []} with Query(): {'tags': ['a', 'b']}
#15 GET /users/me -> 422 Input should be a valid integer, unable to parse string as an integer
#16 POST /orders -> 307 redirect to http://testserver/orders/
#17 GET /greet -> 422 Field required
#18 datetimes -> {'naive': '2026-09-19T10:00:00', 'aware': '2026-09-19T10:00:00+05:30'}
#19 three Depends -> [1, 1, 2]
#27 JSON sent as text/plain -> 422 Input should be a valid dictionary or object to …
2. Async and concurrency
| # | Mistake | Symptom | Fix |
|---|---|---|---|
| 1 | A blocking call (time.sleep, requests, a sync DB driver) inside async def | every request on the worker stalls; health checks time out under load | use async libraries, or make the endpoint def, or run_in_threadpool (part 11) |
| 2 | CPU work in the event loop: pandas, image resizing, PDF generation, bcrypt (0.44 s each, part 17) | latency spikes for everyone during logins or reports | thread pool for short bursts, a process pool or a job queue for heavy work |
| 3 | Thread pool exhaustion: many slow def endpoints or sync dependencies | requests queue although CPU is idle; 40 concurrent sync calls max by default | go async, or raise the AnyIO limiter deliberately in the lifespan |
| 4 | A new HTTP client, engine or SDK client per request | 9× slower calls (part 22), socket exhaustion | create once in the lifespan, share via state or a dependency |
| 5 | Fire-and-forget asyncio.create_task without keeping a reference | work silently vanishes; exceptions are never logged | a durable queue or an outbox (part 13); at minimum keep tasks in a set and log failures |
| 6 | No timeouts on outbound calls | one slow partner API exhausts every worker; cascading outage | explicit connect and read timeouts, limited retries with backoff, circuit breakers |
3. Data and the database
| # | Mistake | Symptom | Fix |
|---|---|---|---|
| 7 | Lazy-loading a relationship with async SQLAlchemy | MissingGreenlet in production paths tests never hit | selectinload, and lazy="raise" to catch it early (part 15) |
| 8 | N+1 queries | an endpoint fine in development takes seconds with real data | eager loading; assert the query count in tests |
| 9 | A session that outlives its request (global, or handed to a background task) | "session is closed", mixed-up data between requests | session per request; background jobs open their own |
| 10 | A transaction held open around an external API call | lock waits, pool exhaustion when the partner is slow | call out first, or record intent in an outbox and commit (part 13) |
| 11 | Unbounded list endpoints, OFFSET pagination on big tables | timeouts and memory spikes as data grows | enforced limits and cursor (keyset) pagination (part 16) |
4. The API contract
| # | Mistake | Symptom | Fix |
|---|---|---|---|
| 12 | Returning raw dicts or ORM objects without a return type, or returning JSONResponse directly | the password hash in the response (demonstrated above) | always declare output models; return data, not Response objects, unless you mean it |
| 13 | One model for input and output | clients set role or owner_id themselves (mass assignment) | separate In, Patch and Out models with extra="forbid" (part 18) |
| 14 | tags: list[str] = [] without Query() | query values silently ignored (demonstrated) | annotate list query parameters with Query() |
| 15 | A fixed path declared after a matching path parameter | /users/me returns 422 (demonstrated) | declare fixed paths first |
| 16 | Inconsistent trailing slashes | 307 redirects; behind a proxy without forwarded headers they point to http:// | one convention, tests for it, correct proxy settings (part 23) |
| 17 | x: str | None without a default | "Field required" for a parameter everyone thought was optional | x: str | None = None |
| 18 | Naive datetimes | times without an offset; clients guess the zone; off-by-hours bugs | timezone-aware UTC everywhere; AwareDatetime in models |
| 19 | Assuming each Depends call runs separately | a dependency used twice runs once per request (demonstrated) | that is usually what you want; use use_cache=False when it is not |
5. Security
| # | Mistake | Symptom | Fix |
|---|---|---|---|
| 20 | CORS allow_origins=["*"] with credentials | any website can make authenticated calls as your users | an explicit origin list (part 12) |
| 21 | Checking who the user is, but not whether they own the object | changing an id in the URL shows someone else's data (BOLA) | scope every query by owner; authorization-matrix tests (parts 18, 19) |
| 22 | Trusting X-Forwarded-For from anyone | IP rate limits and allow-lists bypassed | trust only the proxy's network (part 23) |
| 23 | JWTs decoded without pinned algorithms; tokens or passwords in logs | forged tokens; credentials in the log platform | algorithms=["HS256"] (or your key's), audience and expiry checks; redact logs (parts 17, 21) |
| 24 | No request size limits | a single upload fills memory or disk | limits at the proxy and in streaming upload handlers (part 14) |
6. Operations and upgrades
| # | Mistake | Symptom | Fix |
|---|---|---|---|
| 25 | @app.on_event handlers next to a lifespan | the handler silently never runs (demonstrated); caches never warmed | move everything into the lifespan (part 10) |
| 26 | TestClient(app) without with | tests pass without the lifespan, then production differs | with TestClient(app) as client in a fixture (part 19) |
| 27 | Unpinned FastAPI or Starlette upgrades | 0.132 started rejecting JSON sent with the wrong Content-Type (demonstrated); 0.137 changed app.routes into a tree | pin versions, read release notes, upgrade deliberately behind tests |
| 28 | In-process state with several workers or replicas | rate limits, caches and WebSocket rooms that work in development but not in production | shared state in Redis or the database (parts 14, 18, 22) |
| 29 | A liveness probe that checks the database | a database blip restarts every pod | liveness checks only the process; readiness checks dependencies (part 21) |
| 30 | Deploying without draining | errors on every release; random 502s from keep-alive races | preStop sleep, graceful timeout, keep-alive longer than the LB idle timeout (part 23) |
7. The pre-release checklist
Code review
Output models on every route? Ownership checks on every object access? No blocking calls in async def? Timeouts on every outbound call? Pagination limits enforced?
Tests
Authorization matrix, validation cases, OpenAPI diff against the last release, migrations tested up and down, and the suite run under the lifespan.
Operations
Dashboards for RED metrics per route, alerts on error ratio and p99, request ids in logs, traces sampled, probes split into liveness and readiness.
Release
Pinned dependencies, immutable image tag, expand/contract migrations, rolling update with draining, and a rollback that has actually been tried.
Recap
- The most expensive mistakes are silent: ignored parameters, bypassed response models, handlers that never run.
- Most of the thirty are prevented by three habits: explicit models in and out, a fresh app and lifespan in every test, and measuring before scaling.
- Upgrades are changes too: pin, read the release notes, and let the test suite decide.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.