Part I: The mental model
HTTP bytes on a socket
│
▼
uvicorn (ASGI server: parse HTTP, build scope, call app)
│
▼
middleware stack (ServerError → your middleware → Exception)
│
▼
Starlette router (match path regex + method → APIRoute)
│
▼
dependency graph (solve_dependencies: params, headers, Depends tree)
│
▼
Pydantic validation (body model, coercion, 422 on failure)
│
▼
your endpoint function (event loop if async def, threadpool if def)
│
▼
response_model serialization (filter, encode, JSONResponse)
│
▼
uvicorn sends bytes ...and the same declarations, walked a second
time, become /openapi.json and the /docs UI
The one-sentence identity: FastAPI is a signature reader; it inspects your typed function signatures once at startup and compiles them into a request-handling plan, a validation schema, and an OpenAPI document, all guaranteed to agree because they come from the same source. Everything in this chapter is a consequence of that sentence.
The diagram is worth memorizing because almost none of it is FastAPI's own code. The server is uvicorn speaking ASGI, the standard interface between Python async apps and servers. The middleware, router, request and response objects are Starlette. The validation is Pydantic, whose v2 core is compiled Rust. FastAPI itself is mostly the layer labeled "dependency graph" plus the glue on either side of it: the code that reads your signature, decides which parameter comes from the path, the query string, a header, or the body, resolves it all per request, and serializes whatever you return. That is why the runnable framework fits in one modestly sized Python package while the repository is dominated by tests and translated documentation.
Hold on to the fork at the bottom of the diagram. One set of declarations flows two ways: at request time it drives extraction and validation, and at documentation time the same in-memory structures are walked to emit JSON Schema and OpenAPI. The docs cannot drift from the code because the docs are computed from the code that runs.
Part II: Using it
Install and first session
On Linux and macOS the recommended install is identical: create a
virtual environment and pull the standard extras, which bundle
uvicorn as the server and the fastapi command-line
tool.
python3 -m venv .venv && source .venv/bin/activate
pip install "fastapi[standard]"
A complete application is a file with typed functions. Save this
as main.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
@app.post("/items/")
def create_item(item: Item):
return itemfastapi dev main.pyThat starts uvicorn with auto-reload and prints a small banner ending in lines like these (the exact banner varies by version):
INFO Serving at: http://127.0.0.1:8000
INFO API docs: http://127.0.0.1:8000/docs
INFO Application startup complete.
The payoff is immediate: open /docs and there is a
full interactive Swagger UI describing every route, parameter, and
body schema, generated from nothing but the file above, with ReDoc
at /redoc and the raw document at
/openapi.json. For production the same app runs under
fastapi run main.py or directly with
uvicorn main:app --workers 4.
Progressively deeper examples
First, validation is automatic and errors are structured. Request
GET /items/abc and you get a 422 whose body names the
exact offending field:
$ curl -s http://127.0.0.1:8000/items/abc
{"detail":[{"type":"int_parsing","loc":["path","item_id"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}
Second, constraints live in the annotation. The modern style wraps
metadata in Annotated, which keeps the annotation the
single source of truth and plays well with type checkers:
from typing import Annotated
from fastapi import Query
@app.get("/search")
def search(q: Annotated[str, Query(min_length=3, max_length=50)],
limit: Annotated[int, Query(ge=1, le=100)] = 10):
return {"q": q, "limit": limit}Third, the classic beginner mistake with async. Both of these handlers "work", but the first one freezes the whole server for every other client while it sleeps:
# WRONG: blocking call inside async def stalls the event loop
@app.get("/slow")
async def slow():
time.sleep(2) # nothing else is served for 2 seconds
return {"ok": True}
# RIGHT: plain def is pushed to a threadpool automatically
@app.get("/slow")
def slow():
time.sleep(2) # other requests keep flowing
return {"ok": True}
The rule is simple to state and easy to violate: inside
async def, every slow thing must be awaited. If the
library you call has no await, declare the endpoint
with plain def and let FastAPI run it in a
threadpool. Part V returns to exactly what happens underneath.
Fourth, the response contract is a declaration too. Returning a
database object with a password field is a one-line bug that
response_model turns into a non-event:
class UserIn(BaseModel):
email: str
password: str
class UserOut(BaseModel):
email: str
@app.post("/users/", response_model=UserOut)
def create_user(user: UserIn):
return user # password is filtered out of the response
FastAPI validates the return value against UserOut
and drops every field not declared on it, so the API boundary is
enforced on the way out as well as on the way in. The same
declaration becomes the response schema in /docs.
Part III: When it is the right tool
FastAPI's home turf is the typed JSON API: service backends, ML model serving, internal tools, anything where the contract matters and the workload is I/O-bound. The async foundation means one process can hold thousands of concurrent connections waiting on databases and upstream APIs, and the generated docs turn every service into its own reference. It is also, in my experience, the fastest framework to hand to a new teammate, because the signature of each endpoint is the documentation.
The honest alternatives: Flask remains the better choice for small server-rendered apps and for teams whose entire stack is sync WSGI; it is simpler precisely because it derives nothing from types. Django with DRF wins when you want the batteries, the ORM, migrations, the admin, and an authentication story out of one box; FastAPI gives you none of those and expects you to compose them. Litestar plays the same typed-ASGI game with different opinions and is worth a look if you like the model but not the project governance. And if your bottleneck is raw request throughput rather than I/O concurrency, a Go or Rust service will beat any Python framework; FastAPI's speed claims are about being fast for Python.
The architecture-shaped warning, this system's equivalent of "do not put a writable SQLite file on NFS": do not put a blocking call on the event loop of a single-process deployment, because in an async server concurrency is cooperative, and one uncooperative handler suspends every connection in the process.
DANGEROUS SAFE
one uvicorn process nginx / load balancer
event loop ── async def handler │
│ │ ├─ uvicorn worker 1 ── async I/O
└── blocked by sync DB driver ├─ uvicorn worker 2 (or def →
all other requests wait └─ uvicorn worker N threadpool)
The safe shape is boring: async drivers inside
async def, plain def for anything
blocking, several uvicorn workers per host, and a reverse proxy in
front to absorb slow clients. Lab 2 makes the dangerous shape
measurable, which is the fastest way to make it memorable.
Part IV: The full life of one request
The canonical operation for a web framework is one request, so
follow a single POST /items/ with a JSON body through
every named stage. Two moments matter: import time, when FastAPI
reads your signature and builds a plan, and request time, when the
plan is executed. Beginners look for the magic at request time; it
actually happened at import.
Stage 0: import time, the signature is compiled
When @app.post("/items/") runs, an
APIRoute is created (fastapi/routing.py).
Its constructor calls get_dependant() in
fastapi/dependencies/utils.py, which inspects the
endpoint's signature parameter by parameter and classifies each
one: it appears in the path template, so it is a path parameter; it
is a Pydantic model, so it comes from the JSON body; it is a plain
scalar, so it is a query parameter; it carries an explicit marker
such as Header() or Depends(), so it is
whatever the marker says. The result is a Dependant
(fastapi/dependencies/models.py), a tree of
everything this route needs, with Pydantic fields already built
for each parameter. The route also constructs its response field
from response_model. Nothing about your function is
inspected ever again; requests just execute this structure.
Stage 1: uvicorn accepts and parses
uvicorn's event loop accepts the TCP connection and parses HTTP
with httptools (falling back to h11).
When the request line and headers are in, it builds the ASGI
scope, a plain dict with the method, path, headers, and
query string, and invokes the application:
await app(scope, receive, send). The body is not read
yet; it arrives later through the receive callable.
This is the entire server-framework interface, three arguments,
and it is why FastAPI runs unchanged under uvicorn, hypercorn, or
granian.
Stage 2: down the middleware stack
FastAPI (fastapi/applications.py)
subclasses Starlette's application, which wraps the router in an
onion built once at startup: ServerErrorMiddleware
outermost, then your registered middleware, then
ExceptionMiddleware, which is what turns a raised
HTTPException into a clean JSON response. Each layer
is itself an ASGI app calling the next, so "middleware" is not a
framework concept, just function composition over
(scope, receive, send).
Stage 3: the router finds the APIRoute
Starlette's router walks its route list and asks each route
whether it matches the scope; the path template
/items/{item_id} was compiled to a regex at import
time, and method matching happens here too (a path match with the
wrong method is what produces 405). The matching
APIRoute hands off to the handler that
get_request_handler() in
fastapi/routing.py built at import, wrapped by
Starlette's request_response(), which constructs the
Request object around the scope.
Stage 4: the body is read, dependencies are solved
The handler now reads the body, awaiting the receive
channel until the payload is complete and parsing JSON or form
data by content type. Then solve_dependencies()
(fastapi/dependencies/utils.py) walks the
Dependant tree depth-first: sub-dependencies are
solved before the functions that need them, each extracted value
is validated against its prepared Pydantic field, and every
resolved dependency is cached in a per-request dict so a
dependency shared by three branches of the tree runs once. Errors
do not raise immediately; they accumulate into a list so a single
422 can report every invalid field at once.
Stage 5: Pydantic validates the body model
The body parameter is validated by Pydantic v2, which means the
parsed dict is handed to a validator that was compiled once, at
import, into pydantic-core, the Rust engine. Types
are coerced ("2.5" becomes 2.5 for a float field in
lax mode), nested models recurse, and constraint failures come
back as precise error objects with a loc path into
the document. If the error list is non-empty, the handler raises
RequestValidationError and the exception middleware
renders the 422; your function is never called.
Stage 6: the endpoint runs, on the loop or in a thread
With all values in hand, run_endpoint_function()
makes the single most consequential dispatch in the framework: if
your endpoint is a coroutine function it is awaited on the event
loop, and if it is plain def it is handed to
run_in_threadpool() (Starlette's wrapper over AnyIO's
worker threads) and the loop moves on to other connections while
the thread works. Your function receives exactly the validated,
typed values, never the raw request.
Stage 7: the response is filtered and serialized
The return value goes to serialize_response(): it is
validated against the response field built from
response_model, which is where undeclared fields such
as the password in Part II are dropped, then flattened to
JSON-safe primitives by jsonable_encoder()
(fastapi/encoders.py, one of the most readable files
in the package: datetimes to ISO strings, models to dicts,
recursively). The result is wrapped in a JSONResponse
unless you returned a Response yourself, in which
case FastAPI steps aside entirely.
Stage 8: back out through ASGI, and the schema falls out
The response object is itself an ASGI app; called with the same
three arguments, it emits http.response.start and
http.response.body events that uvicorn turns back
into bytes on the socket. And the parallel output: the first
request to /openapi.json triggers
get_openapi() in fastapi/openapi/utils.py,
which walks the same routes and the same Dependant
trees and Pydantic fields that just served the request, emits JSON
Schema for each model, assembles the OpenAPI document, and caches
it on the app. /docs is a static Swagger UI page
(fastapi/openapi/docs.py) that fetches that
document. Validation and documentation are two traversals of
one data structure, which is the whole trick.
Part V: Internals deep dives
Deep dive 1: how type hints become the API contract
The classification rules from Stage 0 deserve to be explicit, because they are the framework's real API:
| Parameter looks like | Classified as |
|---|---|
| name appears in the path template | path parameter |
| scalar type (int, str, float, bool, ...) | query parameter |
Pydantic BaseModel subclass | JSON body |
explicit marker in Annotated[...] | whatever the marker says: Query, Path, Header, Cookie, Body, Form, File |
Depends(fn) | dependency, resolved recursively |
type is Request, Response, BackgroundTasks... | injected framework object |
The markers are tiny classes in fastapi/params.py,
created by the functions in
fastapi/param_functions.py; they exist only to carry
metadata into the classification pass. Two or more model
parameters make FastAPI expect a JSON object keyed by parameter
name, an embedding rule that surprises people the first time. On
the way out, response_model is the same idea
mirrored: the declared type is the contract, and the actual return
value is coerced into it. Since the return annotation itself can
serve as the response model, a fully typed endpoint reads as a
function type: this request shape in, this response shape out,
checkable by mypy and enforced by the server.
Deep dive 2: the dependency injection system
Depends takes any callable, and because dependencies
declare their own typed parameters, including further
dependencies, each route owns a tree resolved fresh per request:
endpoint(item, user=Depends(current_user), db=Depends(get_db))
│ │
│ current_user(token=Depends(oauth2), db=Depends(get_db))
│ │
└────────── get_db appears twice, runs ONCE ─────────────┘
(per-request cache, keyed by the callable)
The cache is per request, not global: the dict lives for one
request and is keyed by the dependency callable (plus its security
scopes), so get_db shared by the endpoint and by
current_user executes once and both receive the same
session. Pass Depends(fn, use_cache=False) when you
genuinely want a fresh value per use. There is no container and no
registration step, which yields the property I value most:
app.dependency_overrides[get_db] = fake_db swaps the
real dependency for a fake across the whole app in tests, a
pattern I lean on constantly in my own
software projects.
Dependencies written as generators are scoped resources:
def get_db():
db = SessionLocal()
try:
yield db # request runs here
finally:
db.close() # teardown, guaranteed
FastAPI drives the generator one step to get the value, parks it
for the duration of the request, and resumes it afterward for
teardown; code after yield can even catch exceptions
raised by the endpoint and translate them into
HTTPExceptions. One subtlety worth knowing rather
than guessing: by default the exit code runs after the response
has been sent (recent versions add a per-dependency
scope option to close earlier), so do not hold locks
in a yield dependency expecting them released at return. This is
also exactly how the security utilities work: OAuth2 flows and API
keys in fastapi/security/ are ordinary dependencies
that additionally describe themselves to OpenAPI, which is why
/docs grows an Authorize button when you use them,
and why something like a per-user rate limiter drops in as one
more dependency in the tree (the algorithmic half of that story is
in my rate limiter design
write-up).
Deep dive 3: async correctness, the part that pages you at 3am
An event loop serves thousands of connections with one thread by
trusting every coroutine to yield at every await.
That trust is the vulnerability: CPU-heavy loops,
time.sleep(), requests.get(), a sync
database driver, any of these inside async def holds
the loop hostage, and every connection in the process stalls,
including health checks, which is how one slow endpoint turns into
a restart loop in production.
# WRONG: sync HTTP client on the event loop
@app.get("/proxy")
async def proxy():
r = requests.get("https://api.example.com/data") # blocks the loop
return r.json()
# RIGHT: async client, the await yields the loop
@app.get("/proxy")
async def proxy():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()
Three escape hatches, in order of preference: use an async
library; declare the endpoint with plain def so the
whole thing runs in the threadpool; or wrap just the blocking
call with await run_in_threadpool(fn, arg) from
fastapi.concurrency. The threadpool is AnyIO's
worker-thread pool, and its default capacity is 40 threads, which
is the number behind a classic mystery: a service of all-sync
endpoints that plateaus at 40 concurrent requests is not hitting
a CPU limit, it is queueing for threadpool tokens. The dispatch
itself is two lines in run_endpoint_function(), a
nice example of an enormous behavioral difference hinging on
inspect.iscoroutinefunction. Lab 2 measures all of
this directly.
Deep dive 4: Pydantic v2, the Rust floor
Everything data-shaped bottoms out in Pydantic, and v2 changed
the floor: validation logic lives in pydantic-core,
compiled Rust, and a model class is compiled once at import into
a core schema that validates without touching Python bytecode for
the common paths, which is where the order-of-magnitude v1-to-v2
speedups came from. Two concepts are worth holding at the concept
level. First, compilation-at-import is why FastAPI apps do
noticeable work at startup and almost none per request: the
expensive reflection all happens once. Second,
TypeAdapter is the standalone entry point to the
same machinery, letting you validate against any type
(TypeAdapter(list[Item]).validate_python(data))
without defining a model, and it is essentially what FastAPI
holds per parameter. The historical scar tissue is instructive
too: fastapi/_compat/ exists because the framework
spanned Pydantic v1 and v2 for years, and it is a case study in
isolating a dependency migration behind one shim package.
Part VI: Reading the repository
The runnable framework is the single fastapi/ package
at the repo root; most files read in one sitting. A staged plan,
with what you should be able to answer after each stage:
Stage 0, run it under a debugger. The tutorial
app from Part II, a breakpoint inside your endpoint, one request
from /docs. Walk up the stack and name the layers you
recognize from Part IV. You should be able to answer: which frames
are uvicorn, which are Starlette, which are FastAPI?
Stage 1, the spine. Read
fastapi/applications.py (see exactly what
FastAPI adds to Starlette's application class, and
how little it is), then fastapi/routing.py, the true
heart, focusing on APIRoute.__init__,
get_request_handler(), and
serialize_response(). Questions: where is the
request body read? Where does the 422 get raised? What happens
differently when the endpoint returns a Response?
Stage 2, the dependency engine.
fastapi/dependencies/models.py (the
Dependant structure, small enough to memorize), then
get_dependant() and solve_dependencies()
in fastapi/dependencies/utils.py, with
fastapi/params.py and
fastapi/param_functions.py beside them. Questions:
what exactly is the cache key for a resolved dependency? How does
a parameter get classified as query versus body? Where are
generator dependencies parked during the request?
Stage 3, the outputs.
fastapi/encoders.py for
jsonable_encoder(), then
fastapi/openapi/utils.py for the schema walk and
fastapi/openapi/docs.py to see that the docs UIs are
just HTML pages pointing at /openapi.json.
Questions: why is the OpenAPI document cached on the app? What
information does the schema pull from a route that request
handling does not use?
Stage 4, the edges and the foundation.
fastapi/security/ to watch dependencies and
documentation compose, fastapi/concurrency.py (a
re-export that tells you where the real work lives), and then the
best move in the whole plan: read Starlette itself, especially its
routing.py and applications.py, which
are smaller than you fear and explain half of FastAPI by
themselves.
Where not to start: fastapi/openapi/models.py
is hundreds of lines of Pydantic models transcribing the OpenAPI
specification, all shape and no behavior;
fastapi/_compat/ is version-shim archaeology; and the
enormous tests/ and docs/ trees are
where the repo's bulk lives but not its ideas.
Part VII: Hands-on labs
Each lab teaches one concept from the deep dives. All assume the
Part II environment and main.py.
Lab 1: watch the contract update itself. Run
fastapi dev main.py, open /docs, then
add a field tags: list[str] = [] to
Item and save. The server reloads within a second;
refresh /docs and the POST body schema already shows
the new field, and sending a wrong-typed tags already
fails with a 422. Concept: schema, validation, and docs are one
declaration, so they cannot disagree.
Lab 2: block the loop and measure it. Add the
wrong and right endpoints from Part II as /block
(async def + time.sleep(1)) and /thread
(plain def + time.sleep(1)), install
hey,
and load them:
hey -n 20 -c 20 http://127.0.0.1:8000/block
hey -n 20 -c 20 http://127.0.0.1:8000/thread
With 20 concurrent requests, /block serializes: hey's
summary shows total time near 20 seconds and a response histogram
spread from 1s to 20s. /thread finishes in roughly 1
second total because 20 sleeps run in parallel threads. Then push
-c above 40 against /thread and watch
latency step up as requests queue for AnyIO's 40 default
threadpool tokens. Concept: cooperative concurrency and the
threadpool escape hatch, with the pool's capacity made visible.
Lab 3: prove the dependency cache.
from fastapi import Depends
calls = {"n": 0}
def counter():
calls["n"] += 1
return calls["n"]
def sub(c: int = Depends(counter)):
return c
@app.get("/cached")
def cached(a: int = Depends(counter), b: int = Depends(sub)):
return {"a": a, "b": b, "total_calls": calls["n"]}
Every request returns a == b and
total_calls increments by exactly one per request:
the tree references counter twice but the per-request
cache runs it once. Change one edge to
Depends(counter, use_cache=False) and watch two calls
per request appear. Concept: dependency resolution is a cached
tree walk with request lifetime.
Lab 4: response_model as a one-way mirror. Use
the UserIn/UserOut pair from Part II
and post to it:
$ curl -s -X POST http://127.0.0.1:8000/users/ \
-H 'content-type: application/json' \
-d '{"email":"a@b.co","password":"hunter2"}'
{"email":"a@b.co"}
The handler returned an object containing the password; the wire
never saw it. Check /openapi.json and find that the
response schema documents only email. Concept:
serialization is filtered validation against the declared output
type, not json.dumps of whatever you returned.
Lab 5: see a yield dependency's lifetime. Give
get_db-style prints (print("open")
before yield, print("close") in
finally) plus a print inside the endpoint and one in
a background task, then hit the endpoint. The console ordering,
open, endpoint, then close after the response is on the wire,
makes the parked-generator model concrete. Concept: yield
dependencies are scoped resources whose teardown brackets more
than just your function body.
Part VIII: Questions and model answers
Understanding checks; try answering before reading.
1. What is FastAPI in one sentence, structurally? A signature-reading layer over Starlette (ASGI web toolkit) and Pydantic (validation): it compiles typed function signatures into a per-route plan that drives extraction, validation, serialization, and OpenAPI generation from a single declaration.
2. What is ASGI and why does it matter here? The
standard async interface between Python servers and applications:
the app is a callable of (scope, receive, send).
It is why FastAPI is server-agnostic, why middleware is plain
function composition, and why sync and async handlers can coexist
in one app.
3. Trace a POST with a JSON body through the major stages. uvicorn parses bytes into an ASGI scope; the middleware stack passes it to Starlette's router; the matching APIRoute's handler reads the body, solves the dependency tree, validates parameters and the body model with Pydantic, runs the endpoint on the loop or in the threadpool, validates the return value against the response model, encodes it, and the JSONResponse emits ASGI events that uvicorn writes back to the socket.
4. How does FastAPI decide a parameter is query versus
body? By classification at import time in
get_dependant(): names present in the path template
are path parameters, scalars default to query, Pydantic model
types default to body, and explicit markers
(Query, Body, Header...)
override the defaults.
5. When does an endpoint run in a threadpool, and why
is that not free? Plain def endpoints (and
plain def dependencies) run via
run_in_threadpool. The pool defaults to 40 AnyIO
worker threads, so all-sync services plateau at 40 concurrent
in-flight requests per process, and each hop pays thread-switch
overhead, which is why trivial endpoints are better as
async def.
6. What actually goes wrong when you call
requests.get() inside async def?
The coroutine never yields during the network wait, so the event
loop cannot run any other connection's callbacks: every request in
the process stalls for the duration, timeouts and health checks
included. The fix is an async client, a def endpoint,
or wrapping the call in run_in_threadpool.
7. How is a dependency cached? In a per-request
dict keyed by the dependency callable (with its security scopes),
populated during the tree walk in
solve_dependencies(). Shared sub-dependencies run
once per request, never across requests;
use_cache=False opts a single edge out.
8. Why are yield dependencies the right home for a
database session? Because they are scoped to the request
with guaranteed teardown: the framework advances the generator to
get the session, parks it, and resumes it for the
finally block afterward, even on error, and code
after yield can translate exceptions. By default the
exit code runs after the response is sent, which matters if you
hold locks.
9. What does response_model do that a return
type alone would not? It validates and filters
output: the return value is coerced into the declared model and
undeclared fields are dropped, enforcing the contract outbound and
documenting it in OpenAPI. (FastAPI can also use the return
annotation itself as the response model.)
10. Where does /docs come from? A
static Swagger UI page served by the app that fetches
/openapi.json; that document is generated on first
request by walking the same route and field structures used to
serve requests, then cached on the application object.
11. Why did Pydantic v2 make FastAPI apps faster without
FastAPI changing much? Validation moved into
pydantic-core, compiled Rust, with schemas compiled
once at import; since FastAPI delegates all validation and
serialization to Pydantic fields, the floor under every request
got faster for free.
12. When would you pick Flask or Django instead? Flask for small sync apps and server-rendered pages where deriving behavior from types buys little; Django/DRF when the ORM, migrations, admin, and auth batteries outweigh contract-first ergonomics. FastAPI composes best when the product is a typed API and the workload is I/O-bound.
13. A service returns 200s in tests but 500s in
production with "object is not JSON serializable". What
happened? The endpoint returned an object
jsonable_encoder cannot flatten (often a custom class
or an ORM object outside the response model path) on a code path
tests never hit. Declaring a response_model converts
this into either a clean serialization or a loud validation error
at development time.
14. How do you swap a real dependency for a fake in
tests? app.dependency_overrides[real] = fake:
the resolver consults the override map by callable identity at
solve time, so the whole tree under any route picks up the fake
with no mocking framework and no container configuration.
15. Requests hang only under load, CPU is idle, and latency has a hard step at ~40 concurrent. Diagnose. Sync endpoints (or sync dependencies) are queueing for the default 40-token AnyIO threadpool. Confirm by raising the limiter's token count or converting hot paths to async I/O; the step will move or vanish.
16. Why is FastAPI's own package so small relative to its feature list? Deliberate composition: web machinery is inherited from Starlette, data machinery delegated to Pydantic, and FastAPI adds the signature compiler, the dependency resolver, and the OpenAPI walk. Small surface, high leverage.
Part IX: Design lessons
Make one declaration drive many artifacts. The signature produces validation, serialization, docs, and editor support, so they cannot drift. The same move appears in protobuf and GraphQL schemas, Rust's serde, and infrastructure-as-code: wherever N hand-maintained artifacts describe one truth, derive N-1 of them.
Compile at startup, execute at request time.
All reflection (get_dependant, Pydantic schema
building, path regexes) happens once at import; the hot path just
walks prebuilt structures. This is the same shape as regex
compilation, JIT warmup, and query planning: pay for flexibility
where you can afford it, not per operation.
Extend by composing, not rebuilding. FastAPI subclasses Starlette and delegates to Pydantic rather than reimplementing routing or validation, so improvements beneath it arrive for free and knowledge of it transfers downward. Compare wrappers that fork their foundations and inherit their bugs forever.
Design the seams for testing. Dependencies are
plain callables resolved by identity, so
dependency_overrides is a one-line seam and no mock
framework is needed. Any system where collaborators are looked up
through a swappable map (Go interfaces, constructor injection)
gets this property; systems that reach for globals do not.
Keep the escape hatches visible. Return a
Response and serialization steps aside; declare
def and the threadpool absorbs blocking code; drop
to Starlette or raw ASGI when needed. Frameworks age well when
the abstraction is a default, not a cage; the same courtesy shows
up in ORMs that let you write SQL.
Isolate migrations behind a shim.
_compat/ let one codebase span Pydantic v1 and v2
for years by concentrating every version difference in one
place. The pattern generalizes to any dependency major-version
crossing: one adapter module, not conditionals sprinkled through
the tree.
Part X: Memorization framework
One sentence: FastAPI reads typed signatures once at import, compiles them into a dependency tree with Pydantic fields, and at request time solves the tree, validates, runs your function on the loop or in the threadpool, and filters the result through the response model, with OpenAPI as a second walk of the same structures.
bytes → ASGI → middleware → route → dependants → validate
→ endpoint (loop | threadpool) → response_model → bytes
│
└── same structures → JSON Schema → /docs
The chain mapped to source files:
ASGI app + middleware fastapi/applications.py (Starlette base) route + handler fastapi/routing.py (APIRoute, get_request_handler) signature → tree fastapi/dependencies/utils.py (get_dependant) tree → values fastapi/dependencies/utils.py (solve_dependencies) markers fastapi/params.py, param_functions.py encode fastapi/encoders.py (jsonable_encoder) schema fastapi/openapi/utils.py (get_openapi)
Memorize these:
The dispatch rule. async def runs on
the event loop and must never block; plain def runs
in AnyIO's threadpool, default capacity 40.
The classification defaults. In path template →
path; scalar → query; BaseModel → body; markers and
Depends override.
The cache rule. Dependencies cache per request,
keyed by callable; use_cache=False opts out;
overrides swap by identity.
The two walks. Request handling and OpenAPI generation traverse the same route/field structures; that is why docs cannot lie.