FastAPI Deep Intuition
An experienced engineer's guide to FastAPI
1. One-Sentence Essence
FastAPI is a thin layer that turns Python type hints into an HTTP API contract — using Starlette underneath for the network plumbing and Pydantic for the data validation, gluing them together with a dependency-injection system that resolves itself per request.
Every other thing FastAPI does — automatic docs, request validation, response serialization, OAuth helpers — falls out of that core idea. You write Python types; FastAPI reads those types at import time, builds an internal description of what each endpoint expects and returns, and wires up Starlette routes and Pydantic validators accordingly. The framework itself is small. The leverage is enormous.
If you internalize one thing: FastAPI is not a web framework that happens to use type hints. It is a type-hint-driven contract generator that happens to be a web framework.
2. The Problem It Solved
Before FastAPI (released 2018 by Sebastián Ramírez), the Python API landscape had two camps and both were painful in their own way.
Flask camp: lightweight, flexible, but you wrote validation by hand. Every endpoint became a forest of request.json.get("field") lookups, if not isinstance(x, int): return 400, and hand-maintained Swagger YAML files that were always slightly out of date. The code that described your API to humans (docs) and the code that enforced your API at runtime (validation) and the code that hinted to your IDE (types) were three different things, and they drifted.
Django REST Framework camp: batteries included, but heavy. Serializers were a parallel class hierarchy that mirrored your models. Async was a graft, not a foundation. You bought into Django’s whole world to get the API tooling.
The async story made it worse. Python 3.5 introduced async/await in 2015, and by 2018 the language had real concurrency, but Flask and DRF were built on WSGI — a synchronous protocol. You couldn’t await a database call without leaving the framework’s happy path. Aiohttp existed but lacked an opinionated API toolkit. Sanic existed but was niche.
Meanwhile, three things had matured separately and were waiting to be combined:
- Python type hints (PEP 484, 2014) had become idiomatic. People were writing
def f(x: int) -> str:everywhere. - Pydantic (2017) showed you could parse-validate data from types alone, with great error messages.
- Starlette (2018, by Tom Christie of Django REST Framework) was a clean ASGI toolkit — async-native, fast, minimal.
Sebastián’s insight was that nobody had glued these together. If you accepted the premise that types were the source of truth, then the request validation, response serialization, OpenAPI schema, and IDE autocomplete could all be derived from one declaration. The developer would write Python; the framework would do the rest.
The result: a framework where the OpenAPI spec is not maintained, it’s generated; where validation is not written, it’s implied; where async is not bolted on, it’s native. FastAPI didn’t innovate on the parts. It innovated on the integration.
3. The Concepts You Need
Before we go further, here’s the vocabulary. Skim it now; you’ll come back when later sections name-check these terms.
Web protocol layer
- WSGI (Web Server Gateway Interface): the old Python web standard. Synchronous. One request → one function call → one response. Flask, Django (historically), Bottle all speak WSGI. The protocol literally cannot express “this handler is suspended waiting on I/O.”
- ASGI (Asynchronous Server Gateway Interface): WSGI’s successor. Async-native. A handler is a coroutine; the server sends events (“here’s a request”), the handler sends events back (“here’s the response”). ASGI also supports WebSockets and long-lived connections in a way WSGI never could. FastAPI is an ASGI framework. Everything async about it comes from this choice.
- ASGI server: the process that speaks HTTP on the wire and translates it to ASGI events. Uvicorn is the canonical one (built on
uvloopandhttptools). Hypercorn and Daphne are alternatives. - Event loop: the asyncio runtime inside the server process. Single-threaded. Runs one coroutine at a time, switching between them at every
await. The event loop is the unit of concurrency in FastAPI — and the thing you’ll spend most of your “why is it slow?” debugging on. (See Section 7 and Section 10.)
The framework stack
- Starlette: the ASGI toolkit FastAPI is built on. Routing, middleware, request/response objects, WebSocket support, background tasks, the test client — all Starlette. FastAPI inherits all of it. When you read FastAPI source, half of it is
from starlette.x import y. This is deliberate composition, not duplication. - Pydantic: the data validation library FastAPI uses for everything that touches a request body, response, query parameter, or header. Pydantic v2 (released 2023) rewrote the core in Rust (
pydantic-core); validation is now ~5–50x faster than v1 depending on workload. pydantic-core: the Rust validation engine inside Pydantic v2. You don’t import it directly, but you should know it’s there — it’s why “validate every response” is no longer the bottleneck it would have been in 2020.
The FastAPI vocabulary
- Path operation: FastAPI’s term for “an endpoint.” A function decorated with
@app.get("/items"),@app.post(...), etc. The decorator binds an HTTP method + URL pattern to the function. The function is sometimes called a “path operation function.” - Path parameter: a piece of the URL captured into a function argument.
@app.get("/items/{item_id}")withdef f(item_id: int)means the{item_id}portion of the URL is parsed into anintand passed asitem_id. - Query parameter: any function argument that is not a path parameter and not a body.
def f(skip: int = 0, limit: int = 10)exposes?skip=...&limit=.... FastAPI infers this from the parameter not appearing in the path. - Request body: any function argument typed as a Pydantic model (or list/dict thereof). FastAPI parses the request JSON body and validates it against the model.
- Pydantic model (
BaseModelsubclass): a class declaring a typed schema.class Item(BaseModel): name: str; price: float. This single declaration becomes input validation, output serialization, OpenAPI schema, and IDE autocomplete. response_model: the schema FastAPI uses to filter and validate the response. Subtle but critical:response_modelis what the client sees, regardless of what the function returns. If your function returns aUserDB(with hashed password) butresponse_model=UserPublic(no password), the password is filtered out. This is a security feature, not a serialization detail.Depends(): the dependency-injection mechanism.def endpoint(db = Depends(get_db))means “before calling this endpoint, callget_db()and pass its result asdb.” Dependencies can themselves have dependencies. Resolved fresh per request (with one cache per request — see Section 5).- Dependency: a callable (function or class) used as the argument to
Depends(). Anything callable works. Functions withyieldbecome “dependencies with yield” — context managers, basically, with cleanup after the response. - Sub-dependency: a dependency declared by another dependency. Forms a tree resolved per request.
APIRouter: a mountable mini-app. You build features as routers andapp.include_router(...)them. The same idea as Flask Blueprints or Django apps.- Path operation decorator parameters: extras you can pass to
@app.get(...)—tags,summary,description,responses,dependencies,status_code,response_model_exclude_none, etc. They affect docs, OpenAPI, behavior. You’ll use a handful constantly.
Async vocabulary
async defpath operation: runs directly on the event loop. Fast, no thread switch. You promise it doesn’t block.def(sync) path operation: FastAPI runs it in a worker thread (viaanyio.to_thread.run_sync, default pool size 40). Safe with blocking code; costs a thread.- Blocking call: any call that holds the CPU/thread without
awaiting.time.sleep,requests.get, synchronous DB drivers, largejson.dumps,bcrypt.hash— all block. In anasync defendpoint, these stop every other request that worker is handling. - Coroutine: the object you get from calling an
asyncfunction (async def foo(): ...; coro = foo()). It runs only when awaited or scheduled on the loop. Forgetting toawaitis the most common async bug. - Event-loop blocking: when a single request hogs the loop and starves all the others. The defining failure mode of FastAPI in production. (Whole sections on this below.)
Server-side participants
- Worker process: an OS process running one Uvicorn instance with one event loop. Spawning multiple workers (
--workers 4) gives you true parallelism across CPU cores. Each worker has its own memory, its own loop, its own connection pools. - Process manager: a parent process that spawns and watches workers. Uvicorn has one built-in. Gunicorn with the
UvicornWorkerclass is the older convention; modern guidance is “let Uvicorn manage workers, or let Kubernetes manage replicas.” - Lifespan: a single async context manager that runs once on app startup (before
yield) and once on shutdown (afteryield). Replaces the deprecated@app.on_event("startup")decorators. Where your DB engine, HTTP client, and ML models live. - Middleware: ASGI middleware (Starlette/FastAPI) — code that wraps every request. CORS, GZip, request-ID logging, auth pre-checks. Order matters, and they run on the event loop, so don’t block in them.
You now have enough vocabulary. Onward.
4. The Distilled Introduction
Setup
Modern FastAPI assumes Python 3.9+ (most features cleanly support 3.10+). Three packages cover the basics:
pip install fastapi uvicorn[standard]
fastapi[standard] is a meta-extra that pulls in uvicorn, httpx (for the test client), python-multipart (for form/file uploads), email-validator and a few others. Use it if you don’t want to think; pin individually if you do.
Hello world:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_root():
return {"hello": "world"}
Run it:
uvicorn main:app --reload
# or, equivalently with the modern CLI:
fastapi dev main.py
main:app means “module main, attribute app.” --reload watches files and restarts on changes — development only, never production. fastapi dev is the new opinionated wrapper; fastapi run is its production sibling.
What you get for free: the API at http://localhost:8000/, interactive docs at /docs (Swagger UI), and alternative docs at /redoc. The OpenAPI JSON itself is at /openapi.json. You wrote zero schema code; the docs are real and accurate. This is FastAPI’s first wow moment, and you should let yourself notice it because the rest of the framework follows the same pattern.
Path parameters and types
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}
The int annotation does three things at once: parses the URL segment as an integer (returning a 422 if it isn’t), tells your IDE the type, and adds the constraint to the OpenAPI schema. If you’d written item_id: str, it’d accept anything. If you write a Pydantic model, FastAPI knows it’s a body (more on that in a moment). The framework distinguishes path params, query params, and body params by inference rules over the type signature. We’ll see why this matters in the Mental Model.
Need richer constraints? Annotated with Path/Query/Body:
from typing import Annotated
from fastapi import Path, Query
@app.get("/items/{item_id}")
async def read_item(
item_id: Annotated[int, Path(ge=1)],
q: Annotated[str | None, Query(max_length=50)] = None,
):
...
Annotated is the modern, type-checker-friendly way. The older form item_id: int = Path(ge=1) still works but Annotated is preferred — it doesn’t lie to the type checker about default values.
Request bodies with Pydantic
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
description: str | None = None
tags: list[str] = []
@app.post("/items/")
async def create_item(item: Item):
return item
That’s the whole pattern. Anything typed as a BaseModel becomes a JSON request body. Validation happens before your function runs. If the client sends {"price": "not a number"}, FastAPI returns a 422 with a structured error pointing to body.price — you never see the bad data.
The same Item class shows up in the docs as the request schema and response schema, with example values and field descriptions if you provide them:
class Item(BaseModel):
name: str = Field(..., examples=["foo"], description="Display name")
price: float = Field(..., gt=0)
Returning data: response_model is not optional in practice
You’ll see tutorials write:
@app.get("/users/{user_id}")
async def get_user(user_id: int) -> User:
return db.get_user(user_id)
This works, but it conflates two things. The return-type annotation -> User is used as the response model — FastAPI will validate and serialize through it. But there’s a trap: if db.get_user returns a UserDB with sensitive fields (hashed_password, internal_notes), and User is a sibling Pydantic class without those fields, Pydantic v2 will reject the response with a ResponseValidationError unless you explicitly use response_model:
@app.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int):
return db.get_user(user_id) # returns UserDB; FastAPI filters via UserPublic
response_model performs an explicit “shape this object into this schema” pass that drops fields not present in the target. The return annotation alone (in Pydantic v2) is stricter and won’t quietly filter. The pattern most experienced teams settle on: always use response_model for endpoints that return database objects, even when the return annotation could work. It’s explicit about intent and survives Pydantic version changes.
Dependency injection in five minutes
A dependency is any callable. You declare it with Depends:
from fastapi import Depends
def get_settings():
return load_settings_from_env()
@app.get("/health")
async def health(settings = Depends(get_settings)):
return {"env": settings.env}
When a request hits /health, FastAPI calls get_settings() first, passes the result, then calls health. The same pattern handles database sessions, current-user lookup, pagination params, feature flags, anything cross-cutting.
The killer feature: dependencies with yield for setup/teardown:
async def get_db():
db = SessionLocal()
try:
yield db
finally:
await db.close()
@app.get("/items")
async def list_items(db = Depends(get_db)):
return await db.fetch("SELECT * FROM items")
The code before yield runs before your endpoint. The code after runs after the response is sent (in a finally, so it runs even on exceptions). This is how every real FastAPI app handles DB sessions.
Dependencies can depend on other dependencies:
async def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_db)) -> User:
...
@app.get("/me")
async def me(user: User = Depends(get_current_user)):
return user
get_current_user itself depends on oauth2_scheme and get_db. FastAPI resolves the whole tree per request, caching results within a single request (so if two dependencies both depend on get_db, the DB session is created once). We’ll dig into this in Section 5.
You can also attach dependencies to a route without using their result — for pure side effects like auth checks:
@app.get("/admin", dependencies=[Depends(verify_admin)])
async def admin_panel():
...
Routers — splitting up the app
For anything beyond a tutorial, you split routes across files using APIRouter:
# users/router.py
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}")
async def get_user(user_id: int):
...
# main.py
from fastapi import FastAPI
from users.router import router as users_router
app = FastAPI()
app.include_router(users_router)
Routers can have their own dependencies, prefixes, default response classes, and tags. You can include a router inside another router (nesting). For typical apps, one router per domain (users, orders, products) is the rhythm.
Lifespan: startup and shutdown
This is where your DB engine, HTTP clients, ML models, and Redis connections live. The modern API is a single async context manager:
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
engine = create_async_engine(DATABASE_URL)
app.state.engine = engine
yield
# Shutdown
await engine.dispose()
app = FastAPI(lifespan=lifespan)
Code before yield runs once before the first request. Code after runs once on shutdown. The older @app.on_event("startup") and @app.on_event("shutdown") decorators are deprecated — Starlette will eventually remove them. Use lifespan.
You can stash things on app.state and access them inside dependencies via request.app.state.engine. Or close over them in factory functions. Or use request.state for per-request state. Pick one pattern and stick with it.
Async vs sync endpoints
Both work. FastAPI runs them differently:
async def: scheduled on the event loop. No thread switch. Fast. You promise not to call blocking code.def: run in a worker thread viaanyio.to_thread.run_sync. Slightly slower (thread context switch), but blocking calls are isolated to that thread.
@app.get("/fast")
async def fast(): # event loop
return "ok"
@app.get("/sync-ok")
def sync_endpoint(): # threadpool — safe with blocking libs
return some_blocking_call()
@app.get("/danger")
async def danger(): # event loop — and we're blocking it
return some_blocking_call() # ⚠ blocks every other coroutine
This is the single biggest “footgun” in FastAPI. We’ll spend a whole gotcha on it in Section 7. For now: if you async def, you must use await or non-blocking code throughout. If your library is synchronous, just use def.
Errors and validation
Raise HTTPException for client-facing errors:
from fastapi import HTTPException
@app.get("/items/{item_id}")
async def read_item(item_id: int):
item = db.find(item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
For validation errors, you don’t write anything — Pydantic catches malformed input and FastAPI returns a 422 with a structured body listing every problem. You can customize the response shape with @app.exception_handler(RequestValidationError).
For business-domain exceptions (UserAlreadyExists, InsufficientFunds), the clean pattern is: define them as plain Python exceptions in your service layer, then register handlers in the API layer:
@app.exception_handler(UserAlreadyExists)
async def already_exists_handler(request, exc):
return JSONResponse(status_code=409, content={"detail": str(exc)})
This keeps the service layer ignorant of HTTP — it just raises domain exceptions, and the API layer translates.
Background tasks
For work that should happen after the response is sent but doesn’t need durability:
from fastapi import BackgroundTasks
def write_log(message: str):
with open("log.txt", "a") as f:
f.write(message)
@app.post("/items/")
async def create_item(item: Item, background_tasks: BackgroundTasks):
save_item(item)
background_tasks.add_task(write_log, f"created {item.name}")
return {"ok": True}
A few things to know:
- It runs in the same process. If the process dies, the task is lost.
- If you give it a sync function, it goes to the thread pool. If async, it runs on the loop.
- It is not a job queue. No retries, no persistence, no monitoring.
- Use it for: logging, sending an email, invalidating a cache. Do not use it for: anything that must succeed.
For real background work — tasks that must complete, retry, or run on another machine — reach for Celery (with Redis or RabbitMQ), RQ, Dramatiq, or Arq (async-native). Section 8 covers when each is right.
File uploads
from fastapi import UploadFile, File
@app.post("/upload/")
async def upload(file: UploadFile):
content = await file.read() # ← await is essential
return {"filename": file.filename, "size": len(content)}
UploadFile is Starlette’s UploadFile — backed by a spooled temp file, supports streaming. await file.read() is async; file.file.read() is sync (uses the underlying SpooledTemporaryFile). Use the async one in async def endpoints.
Authentication: the standard pattern
OAuth2 password flow with JWTs is the cookbook recipe everyone copies:
from fastapi.security import OAuth2PasswordBearer
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
async def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_db)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
username = payload.get("sub")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = await db.get_user(username)
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
@app.get("/me")
async def me(user: User = Depends(get_current_user)):
return user
OAuth2PasswordBearer is “the dependency that extracts a Bearer token from the Authorization header.” It also makes the /docs UI show an “Authorize” button. The tokenUrl argument is for OpenAPI metadata; it doesn’t do anything at runtime.
For password hashing: use pwdlib with Argon2 (the modern recommendation) or passlib with bcrypt (older, still fine). Don’t roll your own.
For real apps, you usually delegate token issuance to an external IdP (Auth0, Cognito, Okta, Keycloak) and just verify tokens via JWKS in the API. That’s a security best practice — your API doesn’t touch user passwords.
Testing
FastAPI’s TestClient wraps your app in an in-memory ASGI client (HTTPX under the hood):
from fastapi.testclient import TestClient
client = TestClient(app)
def test_create_item():
response = client.post("/items/", json={"name": "foo", "price": 1.0})
assert response.status_code == 200
assert response.json()["name"] == "foo"
No live server, no port, no network. Use TestClient as a context manager (with TestClient(app) as client:) when you need lifespan events to fire (otherwise startup hooks like DB engine creation don’t run).
The killer move is app.dependency_overrides:
def fake_get_db():
return InMemoryDB()
app.dependency_overrides[get_db] = fake_get_db
# in tests, get_db is replaced everywhere it's injected.
# Reset: app.dependency_overrides.clear()
This is the cleanest test isolation in any web framework. You don’t monkey-patch, you don’t import-time trickery — you swap dependencies. Repository, HTTP client, settings, all the same way.
For truly async tests (calling async DB code outside of HTTP), use httpx.AsyncClient with pytest-asyncio:
import pytest
from httpx import AsyncClient, ASGITransport
@pytest.mark.asyncio
async def test_async():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
r = await ac.get("/items/1")
assert r.status_code == 200
The 80% workflow recap
You have enough now to build a real service. The arc is:
- Define Pydantic schemas (request and response).
- Write path operations with proper types and
response_model. - Extract cross-cutting concerns (DB session, current user, settings) into dependencies.
- Group routes into
APIRouters by feature. - Manage resources in a
lifespanhandler. - Test with
TestClientanddependency_overrides. - Deploy with Uvicorn workers (Section 8 dives in).
The next sections explain why this works and where it bites.
5. The Mental Model
Three core ideas. Internalize these and most FastAPI behavior becomes predictable.
Core Idea 1: Type hints ARE the schema. Everything else is derived.
In most frameworks, types are documentation. In FastAPI, types are executable specification. At import time — when Python first imports your module — FastAPI walks through every path operation function, inspects its signature, and decides what each parameter is.
The decision rules are simple:
- Is this argument’s name in the path string (
/items/{item_id})? It’s a path parameter. - Is its type a Pydantic model (or a list/dict of one)? It’s a request body.
- Is its type wrapped in
Depends(...)orAnnotated[X, Depends(...)]? It’s a dependency. - Is its type one of the special types like
BackgroundTasks,Request,Response,UploadFile? It’s that special type. - Otherwise? It’s a query parameter.
Once FastAPI has classified every parameter, it builds:
- A Pydantic model for the request (synthesized from path + query + body).
- A Pydantic model for the response (from
response_modelor the return annotation). - The OpenAPI JSON path entry for this operation.
- The dependency tree for this operation.
All four are generated once, at import time. Per-request, FastAPI just calls validators and dependencies; it never re-parses the function signature.
This predicts a lot of behavior:
- Why
response_model=UserPublicfilters out fields even when you returnUserDB: FastAPI runs a Pydantic conversion throughUserPublic, which doesn’t have those fields, so they vanish. - Why a typo in your type annotation (
item_id: itninstead ofint) crashes at import time, not runtime: the schema is built up front. - Why the
/docspage is always in sync with your code: it’s generated from the same data the validators are built from. - Why FastAPI is so fast despite being Python: the validation isn’t reflection per request. Pydantic v2 builds a compiled (Rust-backed) validator at import time and just runs it on each request.
- Why a request to a route with bad input never reaches your function body: validators run before the function is called.
The mental shorthand: FastAPI compiles your type hints into a pipeline at import time. Requests run through that pipeline. Your function body is just one stage in that pipeline.
Core Idea 2: There is exactly one event loop per worker, and you must respect it.
This is where most production incidents come from. Internalize it.
When you run uvicorn main:app, you get one Python process with one event loop. That loop is single-threaded. It runs all of your async def endpoints, plus every Starlette middleware, plus every dependency that’s async def, plus all the housekeeping. Concurrency on the loop comes entirely from cooperative scheduling: a coroutine runs until it awaits, at which point it yields control and another coroutine can run.
If a coroutine never awaits — if it sits in a tight loop or calls time.sleep(5) or requests.get(...) (the synchronous one) — the loop is blocked. Every other request, every health check, every background task is paused. From outside, the service looks frozen.
FastAPI gives you two doors:
async defpath operations run directly on the loop. They get full concurrency benefits if the code inside is non-blocking (usesawait, calls async libraries, doesn’t touch sync I/O).defpath operations are delegated to a thread pool (Starlette’s, sized 40 by default). Blocking is fine there, because each thread can be busy independently. But threads are heavier than coroutines, and the pool is finite — if 40 sync requests are in flight, the 41st waits.
The implications cascade:
- A “fast”
async defendpoint that callspsycopg2.connect(...)is slower than the same endpoint as adef, because in the async version it blocks the loop, taking down all other requests; in the sync version it just consumes one thread. - An async endpoint that calls
await asyncio.sleep(1)is fine (loop yields). The same withtime.sleep(1)is catastrophic (loop blocked). - Heavy CPU work (
json.dumpson a 50MB object,bcrypt.hashpw, image resizing) blocks the loop inasync defeven though it’s not I/O. Move it to a thread (await anyio.to_thread.run_sync(cpu_bound_fn, args)) or a process pool. - Long-running operations don’t belong in any endpoint. Push them to background tasks, or to a real queue (Celery).
This predicts:
- Why “I added
asyncand it got slower” happens: youasync-ified the signatures but not the dependencies underneath. - Why “the service hangs intermittently in production” happens: one slow blocking call per request is fine at low traffic, lethal at high traffic.
- Why Kubernetes liveness probes start failing under load: the probe endpoint is a coroutine waiting on an event loop that’s blocked.
- Why mixing one synchronous library into an async app causes mysterious tail-latency spikes.
The mental shorthand: async def is a contract. The framework trusts you. Break the contract and you take down the worker.
Core Idea 3: Dependencies are a per-request graph, resolved lazily, cached for the request, with proper teardown.
Depends() looks like a small convenience. It is actually the spine of every nontrivial FastAPI app.
Here’s what happens when a request hits an endpoint:
- FastAPI looks up the dependency tree it built at import time for this path operation.
- It walks the tree, calling each dependency in topological order.
- For each dependency, it inspects its parameters and resolves those dependencies first (recursive).
- Within a single request, calling the same dependency twice returns the cached result. So if
get_dbis depended on byget_current_userand also directly by your endpoint, the DB session is created once and shared. - If a dependency uses
yield, FastAPI keeps the generator alive after the response and runs the post-yield code in the cleanup phase (in reverse order of setup — proper unwinding). - The cache is per-request. Next request, fresh dependencies.
This is dependency injection done right. It predicts:
- Why your DB session lives exactly as long as the request: that’s the contract of a
yielddependency. - Why two endpoints sharing
get_current_userdon’t double-fetch the user from the DB within a request — but do across requests. - Why test overrides via
app.dependency_overrides[get_db] = fake_get_dbwork for every placeget_dbis injected, no matter how deeply nested: the resolver checks the override map before calling the real function. - Why you should never instantiate a connection pool inside a dependency function — the pool would be created per request. Pools belong in
lifespan; the session/connection (one per request) is what the dependency hands out. - Why circular dependencies are impossible to declare (Python won’t let you import them) but accidental N+1 patterns are easy to introduce.
The mental shorthand: a dependency is a per-request scope with proper cleanup. Use it for anything whose lifetime is a request. Use lifespan for anything whose lifetime is the process.
Putting the three together
When a request hits your FastAPI app:
- The ASGI server (Uvicorn) hands raw bytes to Starlette.
- Starlette routes the request to a path operation.
- FastAPI runs the import-time-compiled pipeline: parse path/query/body, validate against Pydantic schemas, resolve the per-request dependency graph.
- Your endpoint function runs — on the event loop if
async def, on a thread ifdef. - The return value is funneled through
response_model(filter + validate) and serialized to JSON. - Dependency teardown runs in reverse order.
- Starlette returns the bytes to Uvicorn, which writes them to the socket.
Every gotcha, every judgment call, every failure mode in the rest of this document maps to one or more of these three ideas.
6. The Architecture in Plain English
Let’s narrate a single request, top to bottom, and watch where state lives.
The pieces
┌──────────────────────────────────────────────────────────────────┐
│ Operating system socket (port 8000) │
└──────────────────┬───────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Uvicorn process │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ asyncio event loop │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Starlette ASGI app │ │ │
│ │ │ ┌────────────────────────────────────────────────┐ │ │ │
│ │ │ │ Middleware stack (CORS, GZip, custom) │ │ │ │
│ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ Router → path operation │ │ │ │ │
│ │ │ │ │ ┌────────────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ FastAPI: validate, inject, call │ │ │ │ │ │
│ │ │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ │
│ │ │ │ │ │ │ Your endpoint function │ │ │ │ │ │ │
│ │ │ │ │ │ └─────────────────────────────┘ │ │ │ │ │ │
│ │ │ │ │ └────────────────────────────────────┘ │ │ │ │ │
│ │ │ │ └──────────────────────────────────────────┘ │ │ │ │
│ │ │ └────────────────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ Worker thread pool (default size 40, for sync `def` work) │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Everything inside the event loop is single-threaded and cooperative. The thread pool exists to keep blocking code off the loop.
Where state lives
This is the question you should always be able to answer:
- Process-level state (DB engine, HTTP client, ML model, Redis pool): created in
lifespan, attached toapp.state. Lives for the whole process. One per worker. This is where you put expensive things you create once and reuse forever. - Request-level state (DB session, current user, request ID, traceparent): created in dependencies (with
yieldfor cleanup). Lives for one request. Cached within the request. One per request. - Event-loop-level state: there isn’t really any “shared state” between concurrent requests on the same loop unless you make it. If you do — say, a shared cache — you have to think about whether two concurrent requests writing it could interleave. (Single-threaded means no torn reads, but does NOT mean atomic across
awaitpoints.) - Cross-worker state: there isn’t any. If you have 4 Uvicorn workers, you have 4 copies of the DB engine, 4 in-memory caches, 4 of everything. Cross-worker coordination needs an external store (Redis, Postgres advisory locks, etc.). This bites people who put a counter in a global variable.
The path of a request, step by step
- TCP: a client opens a connection to port 8000. The OS hands it to Uvicorn.
- HTTP parsing: Uvicorn’s
httptools-based parser reads bytes off the socket and assembles an HTTP request. Fast, written in C. - ASGI scope: Uvicorn converts the parsed request into an ASGI “scope” dict (method, path, headers, etc.) and an ASGI “receive” callable. It then calls
app(scope, receive, send)—appis your FastAPI/Starlette instance. - Middleware stack: Starlette wraps the call with each registered middleware in reverse order. CORS checks Origin, GZip prepares to compress, your custom middleware does whatever it does. All on the event loop.
- Routing: Starlette’s
Routermatches the path (it uses a compiled regex per route) and selects a path operation. - FastAPI machinery takes over:
- Resolves dependencies (per-request graph, with
yieldcleanup registered). - Parses path params, query params, and (if applicable) reads the body.
- Runs Pydantic validation on each. On failure: returns 422 with a structured error before your function runs.
- Resolves dependencies (per-request graph, with
- Your function runs:
- If
async def: directly on the loop. Awaiting suspends; control returns to the loop, which can run other coroutines while you’re waiting on I/O. - If
def: dispatched viaanyio.to_thread.run_syncto a thread from the worker pool. The loop is free during that time.
- If
- Return value processing: FastAPI calls
jsonable_encoderon the return value, runs it throughresponse_modelvalidation/filtering, then serializes to JSON (using the standard libraryjsonby default; you can swap inorjsonviaORJSONResponsefor 2–5x throughput). - Response sent: bytes go through GZip middleware (if configured), back to Uvicorn, out the socket.
- Teardown:
yielddependencies’ cleanup phases run in reverse order.BackgroundTasksstart.
What’s actually fast about it
People say “FastAPI is fast.” That’s slightly misleading. FastAPI itself adds overhead — more than Starlette alone, because it does extra work (Pydantic validation, dependency resolution, OpenAPI bookkeeping). What’s fast is:
- Uvicorn: written around
uvloop(libuv-backed event loop, drop-in for asyncio) andhttptools. Both are C-extensions. Raw HTTP throughput rivals Node.js. - Pydantic v2: the core validators are Rust (
pydantic-core). Validation that used to dominate request CPU time barely registers now. - Async I/O: when paired with async drivers (asyncpg, aiohttp, redis-py async), one worker handles thousands of concurrent connections without thread overhead.
If you benchmark a “hello world” FastAPI route, you’re mostly measuring Uvicorn + Starlette. If you benchmark a real CRUD endpoint, you’re measuring Pydantic v2 + asyncpg + your code. FastAPI’s contribution is mostly: it didn’t slow you down, and it made the code easy to write.
The threadpool is your friend, until it isn’t
The default 40-thread pool sits between async and sync. It’s used by:
- Every
def(non-async) path operation. - Every
defdependency. BackgroundTasks.add_task(sync_function).await UploadFile.read()if the underlying file is on disk (some file ops are dispatched).anyio.to_thread.run_sync(...)calls you make explicitly.
40 is plenty for most apps. But if you have a bunch of slow sync DB calls and 40+ requests in flight, the pool fills, and new sync requests queue waiting for a thread. The async loop is free, but it can’t help — those requests are blocked at the dispatch step.
You can resize it (anyio.to_thread.current_default_thread_limiter().total_tokens = 100) but if you find yourself doing that, it’s a smell that you should be using async libraries instead.
7. The Things That Bite You
Each of these connects to one of the three core ideas. The pattern: what you’d expect → what actually happens → how to handle it.
7.1 Sync code in an async def endpoint silently kills concurrency
What you’d expect: declaring an endpoint async def makes it concurrent.
What actually happens: only if the code inside is non-blocking. The classic version:
@app.get("/users/{id}")
async def get_user(id: int, db = Depends(get_db)):
user = db.query(User).get(id) # ← synchronous SQLAlchemy: BLOCKS the loop
return user
async def means the function is a coroutine. But db.query(...).get(id) is a synchronous call. Python doesn’t yield control during it. The event loop is frozen until the DB returns. Every other request, every health check, every webhook delivery is on hold. CPU usage looks moderate (50–60%) — that’s the giveaway, because the loop isn’t busy, it’s blocked. (Connects to Mental Model #2.)
How to handle: either use an async library end-to-end (asyncpg + SQLAlchemy 2.0 async, or databases, or Tortoise ORM), or change the endpoint to def so FastAPI dispatches it to a thread. The worst combination is async def + sync libraries.
A subtler version: bcrypt.hashpw(password) in an async def login endpoint. It’s not I/O, it’s CPU, but Pydantic CPU is microseconds and bcrypt CPU is hundreds of milliseconds (intentionally). Move it to a thread: await anyio.to_thread.run_sync(bcrypt.hashpw, password, salt).
7.2 response_model validates everything, including very large lists
What you’d expect: response_model=List[Item] is just metadata for docs.
What actually happens: FastAPI validates every item through the Pydantic model on the way out. For a list of 10,000 items with nested models, that’s a non-trivial CPU cost. (Connects to Mental Model #1 — the schema is executable.)
How to handle:
- For very hot endpoints returning large collections, consider
response_model=Noneand returning a pre-serialized response directly (JSONResponse(content=data)orORJSONResponse(...)). You lose the safety filter, so be sure your data doesn’t carry sensitive fields. - For typical endpoints, paginate. If you have 10,000 items in a single response, your problem is bigger than serialization.
- Use
ORJSONResponseas the default response class app-wide — it’s faster than the standard library’sjsonfor typical payloads.
7.3 Pydantic v1 → v2 migration: subtle behavior changes
What you’d expect: bumping Pydantic versions is a routine version pin.
What actually happens: Pydantic v2 is a ground-up rewrite. The validation engine moved to Rust. APIs renamed (.dict() → .model_dump(), .parse_obj() → .model_validate(), Config class → model_config dict). Some behavior tightened — for example, in v1 returning a subclass of your response_model was silently filtered; in v2 it can raise ResponseValidationError if the config doesn’t allow it.
How to handle: pin Pydantic and FastAPI versions tightly. If you must run Pydantic v1 patterns under v2, use from pydantic.v1 import BaseModel (Pydantic v2 ships v1 as a submodule) — but FastAPI itself doesn’t fully support v1-via-v2. Easiest path: do the v2 migration in one focused effort. Don’t mix.
7.4 The dependency cache is per-request, not per-app
What you’d expect: Depends(load_settings) only reads env vars once per process.
What actually happens: it’s called every request, then cached for the rest of that request only. Next request, fresh call. (Connects to Mental Model #3 — request-scoped.)
How to handle:
- For truly process-scoped things (settings, model weights, HTTP clients), wrap the loader with
@lru_cacheor initialize inlifespan. The dependency just hands out the cached singleton. - A common pattern:
@lru_cache def get_settings(): return Settings()—Depends(get_settings)gives the same instance every request. - For per-request things (DB sessions, current user) the per-request cache is exactly what you want.
7.5 BackgroundTasks runs in your process — a process restart eats them
What you’d expect: you scheduled work; the work runs.
What actually happens: BackgroundTasks adds the callable to a list that runs after the response is sent — in the same process. If the worker is restarted (OOM, deploy, max_requests recycle, panic), in-flight tasks are silently dropped. There are no retries.
How to handle: BackgroundTasks is for fire-and-forget niceties — log writes, cache invalidation, “send a welcome email” where one missed email won’t ruin a customer’s day. Anything that must succeed needs durability: a real queue (Celery, Dramatiq, RQ, Arq) backed by Redis or RabbitMQ.
7.6 Mutating Pydantic input models in your function changes the request
What you’d expect: item: Item gives you a snapshot of the request.
What actually happens: item is a real Pydantic instance; mutating it modifies the object you’ll likely also pass to your service layer or DB. In Pydantic v2, models are mutable by default unless you set model_config = ConfigDict(frozen=True). Mutating in the endpoint can cause confusing bugs in middleware, in response_model filtering, in test fixtures.
How to handle: treat input models as read-only by convention. Use item.model_copy(update={...}) or build a separate domain object (ItemDB(**item.model_dump())). Adopt frozen models for inputs in security-sensitive code.
7.7 The interactive docs lie about authentication if you misconfigure CORS
What you’d expect: /docs works the same as your API.
What actually happens: if you have CORS misconfigured for your production frontend but not for the docs origin, the docs page can still call your API directly (same origin), but a browser-based client from your frontend cannot. This makes “but it works in /docs!” a daily Slack message. Conversely, if allow_origins=["*"] and allow_credentials=True, browsers reject the response — they’re mutually exclusive per the CORS spec.
How to handle: be explicit about CORS in production. List origins. Don’t use ["*"] in any app that has authenticated endpoints. Test from your actual frontend’s origin, not just /docs.
7.8 Pydantic’s Optional[X] vs X | None vs default value have different OpenAPI shapes
What you’d expect: description: str = None and description: Optional[str] = None and description: str | None = None all mean “nullable string.”
What actually happens:
description: str = None: Pydantic v2 will reject this — the type saysstr, the default saysNone, mismatch.description: Optional[str](no default): required, can be None — the client must sendnullor a string.description: str | None = None: optional, defaults to None.
The difference between “optional field” and “required nullable field” is a real distinction that bites API consumers when you cross it accidentally. (Connects to Mental Model #1 — types ARE the contract.)
How to handle: be explicit. str | None = None for optional. str | None (no default) for “must be provided, possibly null.” Don’t write Optional[str] = None and str | None = None in the same codebase — pick one style.
7.9 Lifespan errors are easy to swallow
What you’d expect: an exception in your lifespan startup section will obviously crash the app.
What actually happens: depending on the ASGI server and configuration, a startup error can either crash loudly, OR cause the lifespan to fail silently and the server to start anyway with a partially-initialized app — leading to bizarre runtime errors when a route tries to access app.state.engine and finds nothing.
How to handle: log explicitly in lifespan. Wrap critical startup work in try/except with a clear log + raise. Test your lifespan with with TestClient(app) as client: (the context manager triggers startup/shutdown). Don’t put unimportant stuff in lifespan that could fail and obscure real failures.
7.10 Streaming responses + dependency cleanup + the loop = subtle ordering issues
What you’d expect: a StreamingResponse returns immediately; cleanup happens at the end.
What actually happens: with a yield dependency wrapping a streaming response, the cleanup after yield doesn’t run until the stream is exhausted (or the connection is closed). If you yield a DB session and stream from a query, the session lives the entire stream. That’s actually what you want — but it means a slow client can hold a DB connection for minutes. Combined with a small connection pool, this is a denial-of-service waiting to happen.
How to handle: for streaming endpoints, either use a separate connection pool with strict timeouts, or buffer the result before returning. Set client-side timeouts and a keepalive_timeout on Uvicorn. Monitor connection pool usage.
8. The Judgment Calls
The list of decisions where experienced FastAPI engineers diverge from beginners. For each: the real tradeoff and what experienced teams actually pick.
8.1 async def vs def for endpoints
The decision: write every endpoint async def, or only those that genuinely need async?
Option A — All async def: consistent, ready for async libraries, no thread overhead. But every blocking call you forget becomes a production incident. Discipline-heavy.
Option B — Mix freely: use async def only when the body actually awaits something; use plain def for sync code paths. Safer; FastAPI’s threadpool absorbs the cost.
What experienced engineers do: depends on the codebase.
- New code, async-first stack (asyncpg, httpx, redis-py async): all
async def. The discipline is worth it because you get the async benefits. - Mixed legacy (sync ORM, sync HTTP clients, sync SDKs): all
def. Don’t pretend to be async. Let the threadpool handle isolation. You’ll lose a few microseconds; you’ll gain operational safety. - Migrating:
defis your safety net during the migration. Don’t async-ify signatures without async-ifying dependencies.
The signal: if you find yourself writing await anyio.to_thread.run_sync(...) more than rarely, you have a sync-in-async codebase pretending otherwise. Either commit to async libraries or drop back to def.
8.2 Layered architecture vs flat structure
The decision: separate routers / services / repositories / schemas, or keep things flat?
Option A — Flat (all in main.py or per-feature files): faster to write, easier to scan, less ceremony. Works great up to ~10 endpoints.
Option B — Layered: routers handle HTTP concerns, services hold business logic, repositories handle DB access, schemas are pure Pydantic. Testable in isolation. More boilerplate.
What experienced engineers do: package by feature (domain), not by file type. The dominant pattern (popularized by the Netflix Dispatch project and zhanymkanov’s well-known FastAPI best-practices repo) is:
src/
users/
router.py # FastAPI endpoints (HTTP concern)
schemas.py # Pydantic models (request/response)
models.py # SQLAlchemy models (DB)
service.py # Business logic (no FastAPI imports)
dependencies.py # FastAPI Depends() functions
exceptions.py # Domain exceptions
orders/
...
core/
config.py
database.py
Each domain is self-contained. The service.py layer doesn’t know about FastAPI — it gets a DB session, returns domain objects, raises domain exceptions. The router translates HTTP ↔ domain.
The signal: if you find yourself import-cycling between routers, it’s time to split into services. If you can’t unit-test your business logic without spinning up FastAPI, your service layer is leaking.
8.3 Where to put database session lifecycle
The decision: how long does a DB session live?
Option A — Per-request via dependency with yield: standard pattern. One session opens at request start, commits/rolls back at end, closes.
Option B — Per-operation via context manager inside the service: each business operation opens its own session. Flexible, but you can lose the per-request transaction guarantee.
Option C — Global async session (async_sessionmaker): created in lifespan, the dependency calls async_session_maker() to spawn a fresh session per request.
What experienced engineers do: Option C with explicit transaction control:
# core/database.py
engine = None
async_session_maker = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global engine, async_session_maker
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=10)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
yield
await engine.dispose()
# dependency
async def get_db() -> AsyncIterator[AsyncSession]:
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
Engine + sessionmaker live for the process. Sessions live one per request. The try/except/commit/rollback makes the request boundary the transaction boundary.
The signal: if your endpoints have explicit db.commit() calls scattered through service code, the transaction boundary is fuzzy. Decide on a single boundary (usually the request) and enforce it in the dependency.
8.4 Synchronous SQLAlchemy vs async SQLAlchemy
The decision: which database access pattern?
Option A — Sync SQLAlchemy + def endpoints: mature, all features work, well-documented. The threadpool handles concurrency.
Option B — Async SQLAlchemy 2.0 + asyncpg + async def: better tail latency at high concurrency, no thread overhead, fewer connections needed for the same throughput.
What experienced engineers do:
- For low/medium-traffic apps (< 200 RPS per worker): sync. The simplicity dividend is real. The async benefit is small.
- For high-concurrency I/O-bound apps (gateways, fan-out APIs, real-time): async. The win is significant.
- For apps where one endpoint runs slow analytical queries: definitely async, otherwise one slow query monopolizes a thread for seconds.
The honest tradeoff: async SQLAlchemy is harder. Lazy loading doesn’t work the same way. Many ORM features need async with patterns. Test setup is more complex (event loop fixtures, transaction rollback per test). Don’t go async to be fashionable — go async because your traffic profile demands it.
8.5 BackgroundTasks vs Celery vs Arq vs cloud queue
The decision: how do I handle work that shouldn’t block the response?
Option A — BackgroundTasks: built-in, runs in your process, no infrastructure. No retries, no persistence.
Option B — Celery (with Redis or RabbitMQ): industry standard. Persistent, retries, scheduled tasks (Celery Beat), monitoring (Flower). Heavy. Sync worker model (although it has an async beta).
Option C — Arq: async-native, Redis-backed. Lighter than Celery, simpler API. Less mature ecosystem.
Option D — Dramatiq, RQ: middle ground. RQ is dead-simple but Redis-only. Dramatiq has a cleaner API than Celery.
Option E — Cloud queues (SQS + Lambda, Cloud Tasks, Pub/Sub): no Python worker to operate. Workers scale automatically.
What experienced engineers do:
- “Send a welcome email after signup”:
BackgroundTasks. Acceptable to lose one. If it must be reliable, even simple, push to a queue. - “Process an uploaded file”: Celery or Arq. Needs retries, needs to survive restarts.
- “Run nightly reports”: Celery Beat or a real scheduler (Airflow, Cron, cloud scheduler).
- “High-fanout webhook delivery”: cloud queue. Or RabbitMQ if on-prem.
The signal: any time you say “and we’ll retry it if it fails” — you’ve outgrown BackgroundTasks. Move to a queue.
8.6 Uvicorn workers, Gunicorn+Uvicorn, or one-process-per-container?
The decision: how do you run multiple workers?
Option A — uvicorn main:app --workers 4: Uvicorn’s built-in process manager. Simple. Single binary. Modern recommendation.
Option B — gunicorn -k uvicorn.workers.UvicornWorker -w 4 main:app: legacy but mature. Gunicorn provides battle-tested process management (graceful restart, max-requests recycling, worker timeouts). Uvicorn provides ASGI.
Option C — Single Uvicorn process per container, scale via Kubernetes replicas: no in-process workers; let Kubernetes manage replication.
What experienced engineers do:
- On VMs / bare metal: Gunicorn + Uvicorn workers, or Uvicorn workers directly. The features you actually need (max-requests, graceful timeout, jitter, observability per worker) are easier with a battle-tested process manager.
- On Kubernetes / ECS / Cloud Run: single process per container, replicas managed by the orchestrator. One worker per container. This gives the orchestrator a clean view of resource usage and lets HPA/CA scale based on real metrics. Multiple Uvicorn workers inside a single pod confuses Kubernetes — it sees the pod as one unit but actually has 4 event loops competing for the pod’s CPU quota.
The signal: if your scaling strategy is “add more pods,” you want one process per container. If your scaling strategy is “tune the box,” you want a process manager.
8.7 Worker count formula
The decision: how many workers per machine?
The conventional wisdom: (2 × CPU_cores) + 1 — the Gunicorn doc formula. Designed for synchronous workers.
The actual answer for FastAPI: it depends on your workload. For async-heavy (async def + async libraries), one worker per CPU core is the sweet spot — the loop saturates the core, more workers cause context switching. For sync-heavy (def endpoints, sync DB), the (2×cores)+1 formula works better because you’re CPU-bound less of the time.
What experienced engineers do: start with workers = cpu_count for async, (2 × cpu_count) + 1 for sync, and measure. Look at p95 latency vs throughput as you sweep worker count. Memory matters too — each worker is a full Python process with its own connection pools and module imports.
The signal: if adding workers stops improving throughput, you’ve found the limit. If memory grows linearly with workers, evaluate --preload (Gunicorn) or moving to a single-process model with more replicas.
8.8 Pydantic strictness (strict vs lenient parsing)
The decision: should "123" parse as int 123, or fail?
Option A — Lenient (default): Pydantic coerces strings to numbers, "true" to bool, etc. Forgiving.
Option B — Strict: types must match exactly. "123" is rejected for an int field.
What experienced engineers do: be strict on inbound APIs that you control the client of. Be lenient on public APIs where clients are diverse. Within a service-to-service mesh, strict catches bugs early. Across a customer-facing API, strictness causes confusing 422s for clients sending well-meaning data.
Use Field(strict=True) for individual fields, or model_config = ConfigDict(strict=True) for whole models. Apply selectively — a strict UUID field with a lenient containing model is a common, useful pattern.
8.9 OpenAPI customization: when to extend, when to leave alone
The decision: how much do you customize the generated OpenAPI schema?
Option A — Take what you get: don’t fight the tool. Use tags, summary, description, responses parameters on path operations.
Option B — Override app.openapi: full control, but you’re now maintaining a schema generator.
What experienced engineers do: stick with Option A 95% of the time. Add responses={404: {"model": ErrorResponse}, 422: {"model": ValidationErrorResponse}} to document error shapes. Use tags consistently. Set summary/description on every public endpoint.
The cases for B are real but narrow: you need to expose vendor extensions (x-amazon-apigateway-...), hide internal endpoints, or version the OpenAPI spec separately from your code. If you’re not doing one of those, leave it alone.
8.10 Caching
The decision: where do response caches live?
Option A — In-process LRU (@lru_cache): fastest, but per-worker. Stale across workers. Memory accumulates.
Option B — Redis with fastapi-cache: shared across workers, with TTL. Adds a network hop.
Option C — HTTP caching (CDN, reverse proxy): doesn’t touch your app at all. Best for fully public, idempotent GETs.
What experienced engineers do: pick the layer that matches the scope of the cached data.
- Per-process, immutable, small:
lru_cache(settings, parsed JWT public keys, regex patterns). - Cross-worker, mutable, larger: Redis. Use sane TTLs. Beware cache stampede on TTL expiry — implement single-flight or jittered TTLs.
- Public + cacheable by URL: CDN. Adds CDN cost and complexity but offloads enormously.
The mistake is using Redis for what should be lru_cache (paying a network hop for per-process state) or using lru_cache for what should be Redis (drifting between workers).
8.11 API versioning
The decision: how to evolve the API without breaking clients?
Option A — URL path versioning (/api/v1/...): explicit, easy to route, easy to deprecate.
Option B — Header versioning (Accept: application/vnd.myapp.v2+json): clean URLs, harder for caching, harder for /docs.
Option C — Don’t version; only add fields, never break: works for internal APIs.
What experienced engineers do: URL path versioning for external APIs. It’s mundane and it works. Mount each version as its own router (app.include_router(v1_router, prefix="/api/v1")), keep two versions live during a deprecation window, communicate the cutover.
The signal: if you’re afraid to remove a field, you needed versioning a year ago. If you’re versioning every minor change, you’re versioning too much — only major breaking changes should bump the version.
8.12 SQL-first or ORM-first?
The decision: write SQL strings, or use SQLAlchemy ORM?
Option A — Raw SQL with parameter binding: explicit, fast, no ORM magic, no N+1 surprises.
Option B — SQLAlchemy Core: SQL expression language, parameterized, no ORM but composable.
Option C — SQLAlchemy ORM: full mapping, lazy loading, relationships.
What experienced engineers do: contrary to popular tutorial advice, many production teams (including the well-known fastapi-best-practices repo) explicitly prefer SQL or SQLAlchemy Core for queries, Pydantic for validation, and the ORM only for clarifying relationships. The ORM’s magic is also its hazard — lazy loading triggers extra queries, expire-on-commit is a footgun, and N+1 patterns proliferate.
A reasonable middle path: ORM for writes (they benefit from the unit-of-work pattern), Core or raw SQL for reads (where performance matters). Use Pydantic at the boundary.
9. The APIs That Actually Matter
A curated quick-reference. Things you’ll reach for constantly, with the why.
Path operation decorators
@app.get("/items", response_model=list[Item], status_code=200,
tags=["items"], summary="List items",
response_model_exclude_none=True,
responses={404: {"model": ErrorResponse}})
The flags worth knowing:
response_model=...: the schema FastAPI filters output through. Use it deliberately for security (filter sensitive fields).response_model_exclude_none=True: dropNonefields from the response. Cleaner JSON for APIs with many optional fields.response_model_exclude_unset=True: drop fields the caller didn’t set (different from None — keeps explicit nulls, drops unset).status_code=201: the success status. FastAPI uses this for OpenAPI examples and the actual response.tags=[...]: groups operations in the docs.responses={...}: documents non-success responses. Pure metadata, doesn’t affect runtime.dependencies=[Depends(...)]: dependencies whose return value you don’t need (auth checks).deprecated=True: marks the operation deprecated in docs.
Parameter helpers
from typing import Annotated
from fastapi import Path, Query, Body, Header, Cookie, Form, File, UploadFile
async def endpoint(
item_id: Annotated[int, Path(ge=1, le=10000)],
q: Annotated[str | None, Query(max_length=50, regex="^[a-z]+$")] = None,
user_agent: Annotated[str | None, Header()] = None,
session_id: Annotated[str | None, Cookie()] = None,
file: Annotated[UploadFile, File()] = None,
name: Annotated[str, Form()] = ...,
body: Annotated[Item, Body(embed=True)] = ...,
):
The pattern: Annotated[type, marker(...constraints...)]. The marker tells FastAPI where to look (path, query, header, etc.); the constraints get baked into validation and OpenAPI.
embed=True is the one Body trick worth knowing: by default, a single Pydantic body is the whole request body. With embed=True, it’s nested under a key:
- Default:
POST {"name": "...", "price": ...} - Embed:
POST {"item": {"name": "...", "price": ...}}
Pydantic v2 model essentials
from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
from typing import Annotated
class Item(BaseModel):
model_config = ConfigDict(
from_attributes=True, # ← was orm_mode in v1; lets Pydantic read SQLAlchemy objects
str_strip_whitespace=True,
validate_assignment=True, # re-validate when fields are mutated
extra="forbid", # reject unknown fields
)
name: Annotated[str, Field(min_length=1, max_length=100)]
price: Annotated[float, Field(gt=0)]
tags: list[str] = Field(default_factory=list)
@field_validator("name")
@classmethod
def name_must_not_be_admin(cls, v: str) -> str:
if v.lower() == "admin":
raise ValueError("name cannot be 'admin'")
return v
@model_validator(mode="after")
def check_consistency(self):
if self.price > 1000 and not self.tags:
raise ValueError("expensive items must have tags")
return self
The methods you’ll use:
Item(**data)orItem.model_validate(data): parse and validate.item.model_dump(): → dict.item.model_dump_json(): → JSON string.item.model_copy(update={"price": 10}): copy with overrides.item.model_fields_set: which fields were explicitly set (vs defaulted).
Dependencies
from fastapi import Depends
from typing import Annotated
# Function dependency
async def common_pagination(skip: int = 0, limit: int = Query(100, le=1000)):
return {"skip": skip, "limit": limit}
# Class dependency (the class IS the dependency)
class Pagination:
def __init__(self, skip: int = 0, limit: int = Query(100, le=1000)):
self.skip, self.limit = skip, limit
# Used as type alias for cleanliness
PaginationDep = Annotated[Pagination, Depends(Pagination)]
@app.get("/items")
async def list_items(p: PaginationDep):
...
# Yield dependency for cleanup
async def get_db():
async with async_session_maker() as session:
yield session
# Use dependency without using its return value
@app.get("/admin", dependencies=[Depends(verify_admin)])
async def admin(): ...
The class-as-dependency pattern is a tidy way to bundle related parameters. The Annotated[..., Depends(...)] type alias trick keeps endpoint signatures short.
Lifespan
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.db_engine = create_async_engine(DATABASE_URL)
app.state.http_client = httpx.AsyncClient(timeout=10.0)
app.state.redis = await redis.asyncio.from_url(REDIS_URL)
try:
yield
finally:
await app.state.db_engine.dispose()
await app.state.http_client.aclose()
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
Things to know:
- Only one
lifespanper app. To compose, use a library likefastapi-lifespan-manageror write a function that takes other lifespans. - The
try/finallymatters — if startup partially completes, you want as much cleanup as possible on shutdown. - Test with
with TestClient(app) as c:to actually trigger startup.
Middleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
Custom middleware:
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid4()))
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
Two rules:
- Middleware runs on the event loop. Don’t block in it.
- Order matters. Middleware added later runs outermost (the request flows through it first, the response last). CORS should be outermost; auth middleware inner.
Exception handlers
from fastapi import Request
from fastapi.responses import JSONResponse
class NotFoundError(Exception): pass
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
return JSONResponse(status_code=404, content={"detail": str(exc)})
# Override the default validation error handler
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"errors": exc.errors(), "body": exc.body},
)
Pattern: services raise domain exceptions; handlers map them to HTTP. Keeps services framework-agnostic.
Response classes
from fastapi.responses import (
JSONResponse, # default
ORJSONResponse, # 2-5x faster JSON, requires `orjson`
HTMLResponse,
PlainTextResponse,
StreamingResponse,
FileResponse,
RedirectResponse,
)
# Set app-wide default
app = FastAPI(default_response_class=ORJSONResponse)
# Or per-endpoint
@app.get("/items", response_class=ORJSONResponse)
async def items(): ...
# Streaming
async def gen():
for chunk in produce_chunks():
yield chunk
@app.get("/stream")
async def stream():
return StreamingResponse(gen(), media_type="text/event-stream")
# Returning a Response directly bypasses response_model
@app.get("/raw")
async def raw():
return Response(content=b"<svg>...</svg>", media_type="image/svg+xml")
ORJSONResponse is one of the easier wins for a high-throughput API — install orjson, set the default response class, free 2-5x JSON throughput.
Testing
from fastapi.testclient import TestClient
# Sync (uses anyio under the hood; fine for most tests)
client = TestClient(app)
def test_get_item():
r = client.get("/items/1")
assert r.status_code == 200
# Override dependencies
def fake_get_db(): return InMemoryDB()
app.dependency_overrides[get_db] = fake_get_db
# ... run tests ...
app.dependency_overrides.clear() # reset
# Lifespan-aware
def test_with_startup():
with TestClient(app) as client:
# lifespan startup ran; app.state is populated
r = client.get("/health")
Async testing with httpx.AsyncClient:
import pytest
from httpx import AsyncClient, ASGITransport
@pytest.mark.asyncio
async def test_async():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as ac:
r = await ac.get("/items")
CLI tools you’ll touch
fastapi dev main.py: dev server with reload. Equivalent touvicorn main:app --reload.fastapi run main.py --workers 4: production server. Equivalent touvicorn main:app --workers 4.uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --log-level info: explicit form. Worth knowing.gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000: Gunicorn-managed.
Production flags worth knowing:
--limit-concurrency 1000: cap concurrent connections, return 503 over that.--timeout-keep-alive 5: how long to hold idle keep-alive connections.--limit-max-requests 10000 --limit-max-requests-jitter 1000(Gunicorn equivalent:--max-requests,--max-requests-jitter): recycle workers after N requests to prevent memory leaks compounding.
10. How It Breaks
The failure modes that show up in production. For each: symptoms, root cause, diagnosis, fix.
10.1 The hung worker — event loop blocked
Symptoms: latency climbs across all endpoints (not just the slow one). Health checks intermittently fail. CPU on the pod is moderate (50–60%) — not pegged. Throughput plateaus far below what the CPU should support. Adding pods barely helps.
Root cause: somewhere in your async def code path, a synchronous, blocking call is holding the event loop. Could be requests.get, a sync DB driver, bcrypt.hashpw, big json.dumps, time.sleep, an opaque vendor SDK. (Connects to Mental Model #2.)
Diagnose:
- Look at p99 latency vs concurrent requests. If latency scales linearly with concurrency on a single worker, you’re serializing on the loop.
- Add a simple async health endpoint:
async def health(): await asyncio.sleep(0); return "ok". If even that times out under load, the loop is blocked. - Use
asyncio.run(loop.set_debug(True))in dev — it’ll log when a coroutine takes too long without yielding. - Profile with
py-spy dump --pid <worker_pid>— you’ll see the worker stuck in a sync call. - Check what libraries you’re calling. Anything not labeled “async” probably blocks.
Fix: change the endpoint to def (cheap, runs in thread pool), or replace the library with an async equivalent, or wrap the call in await anyio.to_thread.run_sync(...). For CPU-bound work, use a process pool — threads can’t help with the GIL.
10.2 ResponseValidationError under Pydantic v2
Symptoms: endpoints worked in v1; after upgrading to Pydantic v2, certain endpoints throw pydantic_core._pydantic_core.ValidationError from the response side. Stack trace points to FastAPI’s response validation.
Root cause: Pydantic v2 is stricter. If your function returns an object that’s a subclass of response_model, v2’s validators don’t quietly accept the extra fields the way v1 did. Standard library dataclasses passing through a Pydantic dataclass response_model is a known broken case.
Diagnose: turn on response validation logging. Find the offending endpoint. Check the actual return type vs the declared response_model.
Fix: explicitly convert in the endpoint: return UserPublic.model_validate(user_db) instead of relying on FastAPI’s auto-conversion. Or set response_model=None and return the right type yourself. Or downgrade to v1 if you’re not ready (but plan the migration).
10.3 422 errors clients can’t parse
Symptoms: clients report “I sent valid data” but receive 422s. The 422 body is a structured Pydantic error array that mobile clients didn’t expect.
Root cause: FastAPI returns its own format for 422s ({"detail": [{"loc": [...], "msg": "...", "type": "..."}]}). Clients expecting {"error": "..."} choke.
Fix: register a custom exception handler for RequestValidationError:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
return JSONResponse(
status_code=400, # or 422 if you prefer
content={"error": "validation_failed", "fields": exc.errors()},
)
Make this part of your API contract from day one. Don’t change the format mid-life and break clients.
10.4 Dependency override didn’t apply
Symptoms: tests that should hit fake services hit real ones. app.dependency_overrides[get_db] = fake didn’t seem to work.
Root cause: usually one of three things:
- You set the override on the wrong app instance (you have two
FastAPI()instances by accident). - You’re overriding the wrong function — perhaps you imported
get_dbfrom two different modules. - The override happened after the test client was constructed in a way that bypassed it.
Diagnose: print id(get_db) from the test and from the production code path. They must match. Print app.dependency_overrides to confirm the override is registered.
Fix: import get_db from one canonical location. Reset overrides between tests with app.dependency_overrides.clear(). Use a pytest fixture that yields the client and resets after.
10.5 Database connection pool exhaustion
Symptoms: under load, requests start timing out waiting for DB connections. Logs show QueuePool limit ... reached. Adding workers makes it worse.
Root cause: each worker has its own connection pool. If you set pool_size=20 and run 4 workers, that’s 80 connections. If your DB server’s max_connections=100, you’re nearly maxed before doing any real work — and Postgres’s max_connections includes overhead.
Diagnose: in Postgres, SELECT count(*), state FROM pg_stat_activity GROUP BY state; shows connection state. In your app, log pool stats periodically.
Fix:
- Use a connection proxy like PgBouncer in front of Postgres. Your app pool talks to PgBouncer; PgBouncer multiplexes onto a small set of real DB connections.
- Reduce per-worker pool size.
pool_size=5× 4 workers + headroom = comfortable. - Don’t stream long responses while holding a connection. Buffer or use a separate pool.
10.6 Request hangs waiting on a thread
Symptoms: in a sync-heavy app, latency spikes happen periodically with no apparent cause. Sometimes 40 requests are in flight and the 41st waits.
Root cause: the AnyIO threadpool has 40 slots by default. Sync def endpoints, sync dependencies, and BackgroundTasks with sync functions all consume slots. If you have long-running sync work, slots fill up.
Diagnose: check anyio.to_thread.current_default_thread_limiter().borrowed_tokens — that’s how many threads are in use right now.
Fix:
- Genuine fix: stop doing slow work in request handlers. Push to a queue.
- Stopgap: increase the limit (
anyio.to_thread.current_default_thread_limiter().total_tokens = 100). Be aware: more threads = more memory, more context switching, GIL contention. - Better: convert to async libraries so the loop handles concurrency without threads.
10.7 OOM from unbounded request bodies / response sizes
Symptoms: workers get killed by the OOM-killer. Memory usage spikes during specific endpoints.
Root cause: FastAPI loads JSON request bodies fully into memory before validation. A 500MB JSON request will eat 500MB. Same for responses — response_model validation builds Python objects in memory.
Fix:
- Set
max_request_sizeon Uvicorn or upstream (Nginxclient_max_body_size). - For uploads, use streaming (
UploadFile.read(chunk_size)). - For large responses, use
StreamingResponseand yield chunks. - For data export endpoints, paginate aggressively or stream NDJSON instead of one big array.
10.8 CORS preflight failures
Symptoms: browser requests fail with CORS errors. Same requests work fine from curl and /docs.
Root cause: preflight OPTIONS requests aren’t matching CORS middleware config. Common causes: allow_origins=["*"] with allow_credentials=True (forbidden combo), missing allow_methods=["*"], or auth middleware running before CORS and rejecting the OPTIONS.
Fix: add CORS as the outermost middleware (added last in code, since later-added run outermost). Be explicit with origins for credentialed requests. Test the actual OPTIONS preflight: curl -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST" https://api.example.com/items -i.
10.9 Lifespan startup hang
Symptoms: app never starts taking traffic. No errors. Just hangs.
Root cause: a startup task in lifespan is blocked. Common: connecting to a Redis or DB that’s unreachable, with no timeout.
Fix: every external connect in lifespan needs a timeout. Use asyncio.wait_for(...) with a sensible bound. Log progress before each step. Consider: should the app fail to start or start in degraded mode if Redis is down? Decide explicitly.
General debugging workflow
When something is wrong and you’re not sure what:
-
Isolate the layer: hit
/openapi.json— does FastAPI itself respond? If yes, routing is fine. Hit a trivial endpoint (@app.get("/__ping") async def(): return "ok"). If that’s slow, the loop or the middleware stack is the suspect, not your business logic. -
Worker stack snapshot:
py-spy dump --pid <worker>shows what every thread is doing. Look for sync calls in async code, contention on locks, or blocked-on-I/O patterns.py-spy top --pid <worker>shows live CPU. -
Event loop debug mode: in dev or staging,
PYTHONASYNCIODEBUG=1. Logs slow callbacks (>100ms by default). -
Log dependency timing: a middleware that times each request and logs request_id, path, status, duration. Find the slow paths empirically.
-
DB & external service first: 80% of “FastAPI is slow” is actually “the database is slow.” Check DB query times, slow-query log, connection-pool stats. Then check your downstream service latencies.
-
Compare: empty endpoint vs real endpoint: if the empty endpoint is fast and the real one is slow, the problem is your code, not the framework. If both are slow, the problem is in the framework, the loop, the network, or the deploy.
11. The Taste Test
How to spot good FastAPI code from across the room. The patterns that distinguish someone who’s read the docs from someone who’s run this in production.
Schemas: beginner vs experienced
beginner:
class User(BaseModel):
id: int
email: str
password: str
is_admin: bool
@app.post("/users")
async def create(user: User) -> User:
saved = save(user)
return saved
One model for everything. Same shape for input, output, internal. The password ends up in API responses by accident the first time someone returns a User from a list endpoint.
experienced:
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str = Field(min_length=12)
class UserPublic(UserBase):
id: int
created_at: datetime
class UserDB(UserBase):
id: int
hashed_password: str
is_admin: bool
created_at: datetime
@app.post("/users", response_model=UserPublic, status_code=201)
async def create(user: UserCreate, db: DbDep) -> UserDB:
return await user_service.create(db, user)
Three models for three roles: input (what we accept), public (what we expose), DB (what we store). password exists only in UserCreate, hashed_password only in UserDB, neither in UserPublic. The response_model enforces the boundary at the framework level — even if a developer accidentally includes the hash, the framework filters it.
Endpoints: beginner vs experienced
beginner:
@app.get("/orders/{id}")
async def get_order(id: int):
db = SessionLocal()
order = db.query(Order).get(id)
if not order:
return {"error": "not found"}
return order.__dict__
DB session created per-call (no pooling), inconsistent error format, returning ORM internals (__dict__), no return type, no status, no response model, sync code in async endpoint.
experienced:
@router.get("/{order_id}", response_model=OrderPublic)
async def get_order(
order_id: Annotated[int, Path(ge=1)],
db: DbDep,
current_user: CurrentUserDep,
) -> OrderDB:
order = await order_service.get_for_user(db, order_id, current_user.id)
if order is None:
raise OrderNotFoundError(order_id)
return order
Annotated path param with constraint, dependency-injected DB and user, service layer does the work, domain exception (handled elsewhere), response_model filters the response. The endpoint is six lines and does one thing: HTTP routing.
Project structure: beginner vs experienced
beginner:
project/
main.py # 800 lines: all routes, all models, all logic
models.py # mixed Pydantic + SQLAlchemy
utils.py # everything else
experienced (domain-organized):
src/
users/
router.py
schemas.py
models.py
service.py
dependencies.py
exceptions.py
orders/
...
core/
config.py
database.py
security.py
main.py # ~30 lines: create_app(), middleware, include routers
conftest.py
tests/
users/
test_router.py
test_service.py
Each domain is self-contained. main.py is wiring, not logic.
Configuration: beginner vs experienced
beginner:
DATABASE_URL = "postgresql://user:pass@localhost/db" # in source
# or
DATABASE_URL = os.getenv("DATABASE_URL") # may be None, no validation
experienced:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: PostgresDsn
redis_url: RedisDsn
secret_key: SecretStr
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
debug: bool = False
@lru_cache
def get_settings() -> Settings:
return Settings()
Settings as a Pydantic model — validated on app start, type-checked, env-file-aware, secrets wrapped (SecretStr won’t print in logs), the same Depends(get_settings) pattern works in tests for overriding.
Async usage: tells
The single biggest tell of experienced FastAPI code: consistency between endpoint async-ness and library async-ness. Either async def everywhere with async libraries, or def everywhere with sync libraries. Mixing is a warning sign — the developer either doesn’t understand the model, or is migrating (which is fine, but should be clearly in-progress).
Other tells:
awaitappears regularly inside async endpoints. If anasync defbody has noawait, it didn’t need to be async.- No
time.sleep,requests.get,urllib.request,psycopg2.connect, or sync ORM calls insideasync def. - CPU-heavy work is wrapped in
await anyio.to_thread.run_sync(...)or pushed to a queue. - Long-running operations are not in endpoints. Period.
Auth: tells
- Password hashing uses Argon2 (
pwdlib) or bcrypt with cost factor 12+. Never SHA-anything for passwords. - JWT verification uses public-key signatures (RS256/ES256) for production, not HS256 with a shared secret (which couples auth issuer and resource server too tightly).
get_current_useris a dependency, applied viaDepends, not a decorator. This way it flows through the test override system.- Authorization is layered on top:
get_current_userreturns the user, thenDepends(require_role("admin"))checks the role. Don’t conflate authn and authz. - For real apps: token issuance is delegated to an IdP. The API only verifies. Less code is more secure.
Tests: tells
- Tests use
TestClientfor happy-path coverage andhttpx.AsyncClientfor async-specific tests. app.dependency_overridesis the test seam, not monkeypatching.- Each test gets a fresh DB state — either a transaction rolled back at the end, or a fresh in-memory DB, or schema dropped/recreated.
- Tests don’t share state through the app; fixtures yield isolated clients.
Error handling: tells
- Domain exceptions defined in service layer (
UserAlreadyExistsError, etc.). - API layer registers handlers that map each exception to an HTTP response.
- Service code never does
raise HTTPException(...)— that’s leaking framework concerns into the domain. RequestValidationErrorhas a custom handler producing the format the API consumers expect.
Things that scream “tutorial code”
from fastapi import FastAPI; app = FastAPI()followed by 50 endpoints inmain.py.- DB connection or session created inside the endpoint body.
response_modelnever used; everything serializes via the return type.- A single Pydantic model used for both request and response, with
Optionaleverywhere because some fields are only present on one side. time.sleepinsideasync def.@app.on_event("startup")instead oflifespan(deprecated since FastAPI 0.93).- Calling
dict()on Pydantic models (v1 method; v2 ismodel_dump()). - No tests, or tests that hit a real database with no isolation.
try/except: passswallowing exceptions inside dependencies.Union[X, None]annotations mixed withOptional[X]mixed withX | None— pick one.- Hard-coded secrets in code or
.envfiles committed to git.
Things that signal real experience
lifespanwith explicit timeout-bounded startup of every external dependency.response_model_exclude_none=Trueset thoughtfully on endpoints with many optional fields.- A separate sync request_id middleware that also threads the ID into log context (using
contextvars). - Dependency type aliases:
DbDep = Annotated[AsyncSession, Depends(get_db)]. - A custom
default_response_class=ORJSONResponseon the FastAPI app. - Health checks that distinguish “ready” (can serve traffic) from “live” (process is alive). Kubernetes-aware.
--max-requestsset on workers to bound memory growth.- A
/versionendpoint that returns the build SHA. Trivial; saves your life during incidents. - Pagination as a class dependency, used uniformly across list endpoints.
- A consistent error response schema documented in
responses={...}for every endpoint.
12. Where to Go Deeper
A curated, opinionated list. The best resource for each role.
The official docs (fastapi.tiangolo.com)
What they’re good for: surprisingly, learning. Sebastián Ramírez wrote them as a tutorial, not a reference. They’re verbose for that reason — read them in order, skim heavily. The “Tutorial - User Guide” section covers 90% of what most apps need. The “Advanced User Guide” has the real production patterns (security, lifespan, custom responses, sub-applications).
When to read it: as your initial path to fluency. Then again, deeper, after six months of writing FastAPI in anger — you’ll see things you missed.
Starlette docs (www.starlette.io)
What they’re good for: understanding what FastAPI is sitting on. Starlette’s docs are short and dense. Reading them clarifies which features (middleware, background tasks, request/response, WebSockets, test client) are inherited from Starlette and which are FastAPI’s own additions. This matters when you debug deep into the stack and find yourself in starlette/routing.py.
When to read it: after you’ve written FastAPI for a while and want to understand the underlying ASGI machinery.
Pydantic v2 docs (docs.pydantic.dev)
What they’re good for: half of FastAPI mastery is Pydantic mastery. Pydantic v2 docs are excellent — the migration guide especially. The “Validators” and “Model Config” sections repay study; most “FastAPI gotchas” are actually Pydantic gotchas.
When to read it: immediately. You can’t get good at FastAPI without getting good at Pydantic.
zhanymkanov/fastapi-best-practices (GitHub)
What it’s good for: the most-cited collection of opinionated FastAPI patterns from a working production team. Project structure (Netflix Dispatch-inspired), naming conventions, async patterns, SQL-first philosophy. Concise — read in one sitting.
When to read it: before starting your second FastAPI project. You’ll save weeks of rediscovering the same patterns.
”Async vs Sync in FastAPI” — Lane Parton (laneparton.com)
What it’s good for: the clearest single article on the most dangerous FastAPI footgun. The author distills hard-won lessons from a Kubernetes-induced production incident. Focuses on the def vs async def decision and what FastAPI actually does for you when you pick each.
When to read it: before you ship anything to production. Then bookmark it for new team members.
”Concurrency and async / await” — official FastAPI docs (fastapi.tiangolo.com/async/)
What it’s good for: Sebastián’s own explanation of why FastAPI handles async the way it does. Long, but it cements the mental model — including the “burgers and parallel cleaning” analogy that’s actually useful for explaining the difference between concurrency and parallelism.
When to read it: as a complement to Lane Parton’s piece. Read both back to back.
Pydantic-Settings docs and python-decouple (or dynaconf)
What they’re good for: configuration. Pydantic-Settings is the standard now — env files, validation, secrets, layered config. Worth understanding well because settings tend to grow into a tangled mess otherwise.
When to read it: as soon as your app has more than 3 config values.
tiangolo/full-stack-fastapi-template (GitHub)
What it’s good for: Sebastián’s own “this is how I’d build a real app” template. FastAPI + SQLModel + Alembic + Postgres + Docker + Traefik + frontend. Heavily opinionated. Reading the code teaches a lot of integration patterns you don’t get from the docs.
When to read it: after building one or two FastAPI apps yourself, when you want to see “how does a complete production setup actually fit together.”
TestDriven.io’s FastAPI courses
What they’re good for: the most thorough applied tutorials beyond the official docs. The Celery + FastAPI guide is the canonical reference for that integration. Their testing guides cover real workflows (pytest, Docker test environments, CI).
When to read them: when you hit a specific need (Celery, testing, deployment) and want a thorough walkthrough rather than scattered blog posts.
The FastAPI source itself
What it’s good for: it’s small and readable. fastapi/dependencies/utils.py is where the dependency resolver lives — reading it clarifies the whole DI model. fastapi/routing.py shows how path operations are registered and how Pydantic schemas are wired in. Half of FastAPI is a layer of glue; reading the glue makes you faster at debugging it.
When to read it: when you have a question the docs don’t answer, or when something does the “wrong” thing and you suspect the framework. It’s not a daunting codebase.
Build something
The final and most important resource: pick a project that matters to you and build it. Authenticate users. Talk to a real database. Deploy to a real server (or container, or platform). Scale it to handle 1000 RPS in a load test. Watch what breaks. Fix it. That’s where the intuition in this document was written from, and it’s where yours will come from too. The docs and articles set you up; the production incidents teach you.
The ideas are mine. The writing is AI assisted