FastAPI

FastAPI is the Python web framework created by Sebastián Ramírez that turned "declare your types and get everything else for free" into the default way to build APIs in Python, built as a remarkably thin layer over Starlette for the web machinery and Pydantic for the data machinery. This is a full chapter, not a tour: a practical tutorial, then the complete life of one POST request from the uvicorn accept loop to the serialized response, deep dives into the three load-bearing subsystems, a staged plan for reading the source, and labs you can run tonight. File paths and behavior were verified against the repository and docs at FastAPI 0.139.

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 item
fastapi dev main.py

That 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 likeClassified as
name appears in the path templatepath parameter
scalar type (int, str, float, bool, ...)query parameter
Pydantic BaseModel subclassJSON 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.

Key takeaway: FastAPI is one idea applied everywhere: the typed function signature is the single source of truth, compiled once at startup into structures that serve requests and generate documentation alike. The repository is worth reading precisely because the framework adds so little machinery of its own; it is a masterclass in getting leverage by composing Starlette and Pydantic instead of rebuilding them, and the one discipline it demands in return, never block the event loop, is the one this chapter's labs make you feel.