FastAPI has quietly become the default answer to “how do I expose this Python function over HTTP?” — for data science teams shipping models, for backend teams building microservices, and increasingly for LLM tooling that needs a clean JSON surface. The project sits at version 0.162.0 as of this writing, and unlike many popular frameworks, its source tree is small enough to read in an afternoon. That is exactly what this post does: we clone the repository, open the handful of files that carry the whole framework, and trace one HTTP request from the ASGI entry point down to the serialized JSON response.
What makes FastAPI worth reading at the source level is that it is deliberately a thin, opinionated layer over two other projects. Starlette provides the ASGI application, routing primitives, middleware and response objects. Pydantic — which we covered separately in our Pydantic tour — provides data models, validation and serialization. FastAPI’s own code is mostly about the glue: declaring parameters declaratively, resolving dependencies per request, and generating a machine-readable OpenAPI schema from everything it learns about your functions. Because the layer is thin, every surprising behavior you meet in day-to-day coding has a short, findable explanation in the source.
The two diagrams below were generated from the actual repository at commit time. The first gives the five-region overview; the second drills into thirty-four symbols across seven regions, distinguishing what happens once at registration time from what happens on every request. Every node in the SVGs links into the real source on GitHub, at the line numbers verified with grep while writing this post.
Figure 1 — FastAPI overview: the application object, the routing layer, the dependency-injection engine, the OpenAPI/Docs region, and the response-encoding region. Click any node to open the exact source file on GitHub.
Reading the overview from left to right:
- class FastAPI, L52 — the application you instantiate; a
Starlettesubclass that adds parameter validation, dependency injection and OpenAPI generation on top of the parent’s ASGI machinery. - APIRouter, L2327 — the registry behind every
@app.get(...)and@router.post(...)decorator; it owns the list of routes and the logic for merging routers together. - APIRoute, L1179 — one path operation. It computes the dependency tree for its endpoint exactly once, at registration time, then reuses that tree for every request.
- get_request_handler, L397 — the factory that returns the per-request closure: solve dependencies, await your endpoint, serialize the result.
- solve_dependencies, L643 — the runtime heart of the DI engine, resolving your declared dependencies (and their sub-dependencies) with caching.
- get_openapi, L577 — introspects the same routes you registered and emits the OpenAPI 3.1 document served at
/openapi.json. - get_swagger_ui_html, L40 — renders the interactive
/docspage that consumes that schema, so documentation is a byproduct of routing, not a separate artifact.
Starlette Below, FastAPI Above
The first thing the source confirms is that FastAPI does not reimplement an HTTP framework. class FastAPI(Starlette) inherits the ASGI callable protocol, the middleware stack and the router plumbing. What the subclass adds is visible in four methods. The constructor at applications.py, L68 accepts the metadata you know from every tutorial — title, version, openapi_url, dependencies, lifespan — and then calls setup() at L1120, which registers the /openapi.json, /docs and /redoc routes on the internal router. build_middleware_stack() at L1032 assembles ServerErrorMiddleware, your user middleware, ExceptionMiddleware and finally the router, in that order. And __call__ at L1175 is the ASGI entry point any server (Uvicorn, Hypercorn, or the fastapi CLI) invokes per connection.
The decorators are pure sugar. @app.get("/") resolves to the method at L1688, which forwards to add_api_route at L1207, which delegates to the same-named method on APIRouter. include_router at L1483 is the same delegation pattern for whole routers, which is why prefixes, tags and router-level dependencies compose the way the tutorials promise. There is no hidden second registration path — everything funnels through one method on one router.
How a Request Actually Travels
With the route registered, a request enters through request_response at routing.py, L129, which adapts the handler — sync or async — into a Starlette-compatible ASGI callable. The real work happens in the closure returned by get_request_handler: it calls solve_dependencies to produce the keyword arguments your function declared, awaits the endpoint, then hands the return value to serialize_response at L320. If you declared a response_model, that function validates the raw result against it and filters out any extra fields — the validation here is the same Pydantic machinery used on input, just in the outbound direction. The validated object goes through jsonable_encoder, L129, which recursively converts Pydantic models, dataclasses, enums and datetimes into JSON-safe structures.
Failure paths are equally explicit. HTTPException at exceptions.py, L17 and RequestValidationError at L212 are converted to JSON by the handlers in exception_handlers.py, and both are installed by the middleware stack built at startup. Side effects that must not delay the response — sending an email, writing a log line — are attached to a BackgroundTasks object (class at background.py, L12), which the DI engine will happily inject as a parameter; the tasks run after the response has been sent. There is even an SSE-shaped escape hatch: EventSourceResponse at sse.py, L20 is a StreamingResponse subclass that formats server-sent events for you.
The Dependency Injection Engine
The dependency system is the part most worth reading because it is the least documented by intuition. At registration time, get_dependant at dependencies/utils.py, L300 walks the endpoint’s signature using get_typed_signature at L242 and produces a Dependant — a dataclass at dependencies/models.py, L32 that holds the callable, its sub-dependencies, and every parameter classified by kind. The classifier is analyze_param at L409: a parameter is a path segment, a query string, a header, a cookie, a request-body field, or itself a dependency, decided by what you wrapped it in.
Those wrappers live in params.py: Depends at line 791, Body at 498, Query at 232, Path at 142, Header at 320, with Form and File deriving from Body, and Security deriving from Depends to carry scope requirements. The friendlier function API you actually type — Depends(...), Query(...), Body(...) — lives in param_functions.py and simply constructs those classes. At request time, solve_dependencies walks the tree recursively, caches results per request (so an expensive dependency used in three places runs once), and collects validation errors from all branches before failing, which is why error responses list every bad field at once rather than just the first.
This design has a pleasant consequence: the same engine serves the docs. get_openapi_path at openapi/utils.py, L303 reads the very same Dependant objects to emit parameter and request-body schemas, so what /docs shows is guaranteed to match what your endpoint accepts. If you have used model servers that expose LLM endpoints over HTTP — say behind LiteLLM gateways or inside Open WebUI — you have consumed APIs whose documentation quality depends on exactly this kind of schema-from-code generation.
From Install to Your First Model
Everything above is easier to internalize once you have the minimal app running. The steps below follow the repository’s own README exactly.
First, install the standard distribution into a virtual environment: pip install "fastapi[standard]". The [standard] extra pulls in uvicorn[standard] for the ASGI server plus the CLI tooling, so you get the batteries-included experience the docs assume.
Second, create a file main.py with the README’s example — this is the whole thing:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
Third, run it with fastapi dev (or uv run fastapi dev if you use uv). The CLI reads main.py automatically, detects the app, starts Uvicorn on http://127.0.0.1:8000 with auto-reload in development mode, and prints the /docs URL alongside it. The README notes that for production you switch to fastapi run. Open /items/5?q=somequery and you will see the JSON response; open /docs and you will see Swagger UI rendering the schema that get_openapi generated from those two decorators — the source tour and the tutorial are the same story told at two altitudes.
The Detail Diagram: Registration Time vs Request Time
The single most useful mental model the source gives you is the split between what happens once, when the route is registered, and what happens per request. The detail diagram below makes that split explicit: the upper region (application and routing) mostly runs at import time, the middle regions (dependency resolution and parameter classification) have a registration phase and a request phase, and the OpenAPI region reads only registration-time facts.
Figure 2 — FastAPI in detail: 34 symbols across 7 regions, from FastAPI.__init__ and build_middleware_stack down to analyze_param, jsonable_encoder and the exception handlers. Node links go to the exact lines on GitHub.
Two details reward attention here. First, APIRoute exposes get_route_handler at routing.py, L1278 as an official override point — subclass APIRoute, return a wrapped handler, and you can intercept every request to that route without touching middleware. Second, APIWebSocketRoute at L854 routes WebSocket connections through the same solve_dependencies engine, which is why WebSocket endpoints can declare Depends(...) parameters and get session-scoped caching for free.
Honest Limits
- The speed claim is the README’s, not mine. The repository’s headline advertises high performance, and the framework does lean on Uvicorn, Pydantic and
orjson/ujsonresponse classes (ORJSONResponse, L78), but the source contains no benchmarks. If throughput matters to you, measure your own workload; the framework will not do it for you. - It is an API framework first. Server-rendered HTML is possible via Starlette’s templating integration, but the core — validation, DI, OpenAPI — is built around JSON request/response cycles. If you want a content site, this is the wrong tool; if you want an API consumed by tools like txt.ai’s semantic search or an MCP server built with the MCP Python SDK, it is the right one.
- The magic is signature-based. DI works by inspecting type hints at import time, so refactorings that rename parameters or change types silently change runtime behavior. Static analyzers see less than the runtime does, and circular dependencies between modules surface as confusing signature errors rather than clean import loops.
- No user management, no ORM, no cache. The
fastapi.securitypackage provides credential extractors (HTTP Basic, OAuth2 bearer flows, API keys), not an auth system. You bring your own database layer, session store and permission model. - Line numbers drift. Every link in this post was verified against
masterat the version above; a few minor releases from now, functions will have moved. The diagrams were generated from the same commit, so clicking through the SVGs will always re-anchor you.
Where to Go Next
If this tour changed how you read framework code, the natural next steps are: read Pydantic’s own source tour to understand the validation layer FastAPI leans on; look at how LiteLLM standardizes provider APIs behind one OpenAI-shaped surface you could serve with FastAPI; or study Open WebUI for a full product built on top of this stack. For semantic search backends, txt.ai pairs naturally with a FastAPI front door, and the MCP Python SDK shows the same declarative-function-over-protocol pattern applied to tool servers. Enjoyed this post? Never miss out on future posts by following us