I went deep into FastAPI
FastAPI feels simple from the outside, but there is a thoughtful structure behind it. This post explains how its internals turn normal Python functions into API endpoints.
FastAPI is one of the most used Python frameworks for building APIs today. If you have worked with Python web backends in the last few years, you have probably seen it somewhere. Maybe in a production service, maybe in a side project, or maybe just through that /docs page everyone mentions when they talk about FastAPI.
What made me curious was not only that FastAPI is popular. Popular frameworks are not always simple inside. What interested me was how small the user-facing code usually looks. You write a function, add a decorator, add some type hints, and FastAPI handles request parsing, validation, JSON responses, dependency injection, and OpenAPI docs.
Recently, while working on Quater, a Python framework for building applications for agents, I started thinking more about framework internals in general. When you are only using a framework, you mostly care about whether it helps you build things quickly. But when you are building something framework-like yourself, you start noticing different things. You start asking how the framework understands user code, where it keeps metadata, how it validates input, and how much work it does before a request even arrives.
That is the reason I opened FastAPI’s source code. I was not trying to read every file or write a repo walkthrough. I mainly wanted to understand the structure of the framework. How does a normal Python function become an API endpoint? How does FastAPI decide what comes from the path, query, body, or headers? How are dependencies connected? And how does it generate docs without making the user write a separate schema manually? Also - thanks to Claude for being a great companion during this exploration.
This post is my understanding of that structure. It is not about the repo layout. It is about the internal shape of FastAPI as a framework.
FastAPI Has A Focused Scope
One of the first things that becomes clear is that FastAPI has a focused scope. It is mainly concerned with building APIs. It gives you routing, request parsing, validation, dependency injection, response serialization, OpenAPI schema generation, Swagger UI, ReDoc, and async support.
It does not try to decide everything about your application. It does not ship with an ORM. It does not include an admin panel. It does not force one project layout. It gives you a strong API layer and lets you choose the rest of your stack.
This is different from something like Django, where the framework gives you a much larger structure out of the box. Django includes the ORM, migrations, admin, auth, sessions, middleware, templates, and a lot more. That is useful when you want that full structure. FastAPI takes a smaller surface area and focuses more deeply on the API part.
That difference is important because it explains a lot about FastAPI’s internals. It is not trying to manage the whole application. It is trying to understand endpoint functions really well.
The Layers FastAPI Builds On
FastAPI does not implement everything from scratch. A lot of its structure comes from how it connects two major libraries:
Starlette -> ASGI, routing, middleware, request/response handling
Pydantic -> validation, parsing, serialization
Starlette provides the lower-level web framework pieces. It handles the ASGI foundation, request and response objects, middleware, and routing base. Pydantic provides the data validation and serialization layer.
FastAPI sits between these pieces and gives the developer a nicer API on top. A request comes through the ASGI/Starlette layer, FastAPI figures out which endpoint should run and what data it needs, Pydantic validates that data, then FastAPI calls the endpoint and prepares the response.
This part of the design is worth noticing. FastAPI’s value is not in rebuilding everything. Its value is in connecting existing pieces in a way that feels natural when writing API code.
Function Signatures Are The Center
The most important idea I found is that FastAPI uses Python function signatures as the center of the framework.
For example:
@app.get("/users/{user_id}")
def get_user(user_id: int):
return {"user_id": user_id}
From this small function, FastAPI understands quite a lot. It knows this is a GET endpoint. It knows the path is /users/{user_id}. It knows there is a path parameter called user_id. It also knows that user_id should be converted to an integer before the endpoint function is called.
If someone calls /users/10, the function receives 10 as an integer. If someone calls /users/abc, FastAPI can return a validation error without calling the function.
This is where type hints become more than editor help. In FastAPI, they are part of the runtime behaviour. They affect validation, conversion, error handling, and documentation.
The same idea applies to query parameters:
@app.get("/search")
def search(q: str | None = None, page: int = 1):
return {"q": q, "page": page}
FastAPI can infer that q and page are query parameters. It knows q can be a string or None, and page should be an integer with a default value of 1.
So the endpoint function itself carries much of the API contract. That is one reason FastAPI code often looks clean. The framework is reading the function carefully instead of asking you to repeat the same information in many places.
Routes Are Prepared Before Requests
FastAPI does a lot of work when a route is registered. It does not wait until a request comes in to understand the endpoint from zero.
When you write something like this:
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
FastAPI analyses the path, HTTP method, endpoint function, parameters, type hints, default values, dependencies, response model, status code, tags, and other metadata attached to the route.
By the time the application starts handling requests, FastAPI already has a prepared route object. That route object knows what values need to be extracted, how they should be validated, which dependencies need to run, and how the response should be handled.
This is a small but important detail. If the framework can understand something once during setup, it does not need to repeat that work on every request. Request handling becomes more direct because a lot of the thinking has already happened earlier.
A FastAPI route is therefore not just a URL mapped to a function. It is a prepared object with enough metadata to drive request handling, validation, response serialization, and documentation.
The Request Flow
Take this endpoint:
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
And this request:
GET /items/10?q=phone
Roughly, the request goes through this flow: the ASGI server receives it, Starlette handles the lower-level routing, FastAPI gets the matched route, extracts the path and query values, validates and converts them, resolves dependencies, calls the endpoint function, serializes the return value, and sends the response back.
The important part is what the endpoint receives. It does not receive raw HTTP strings. It receives Python values that have already gone through validation and conversion. In this case, item_id reaches the function as the integer 10, not the string "10".
This keeps the endpoint focused on application logic. You still need to write good business logic, but you do not need to manually repeat basic parsing and validation for every route.
Dependencies Are Part Of The Route Model
FastAPI’s dependency system is one of the more interesting parts of the framework. It looks simple from outside:
from fastapi import Depends
def get_current_user():
return {"name": "Bhuvnesh"}
@app.get("/profile")
def profile(user = Depends(get_current_user)):
return user
But internally, FastAPI treats dependencies as a graph. The profile endpoint depends on get_current_user. That dependency could depend on another function, and that function could depend on something else.
For example, in a real app, the graph may look like this:
profile endpoint
-> get_current_user
-> get_db
-> get_settings
FastAPI analyses this graph and resolves it for each request. It decides what needs to run first, what values should be passed where, and what can be reused during the same request.
The useful part is that dependencies stay visible in the function signature. When you read the endpoint, you can see what it needs. The dependency system can become hard to follow if overused, but the basic model is clean: dependencies are explicit, composable, and based on normal Python functions.
Dependencies Are Also Just Functions
FastAPI does not create a completely different model for dependencies. A dependency can be a normal sync function, an async function, or a function with its own parameters.
def common_params(q: str | None = None, page: int = 1):
return {"q": q, "page": page}
FastAPI can inspect this function in almost the same way it inspects an endpoint function. It reads the signature, understands what values are needed, validates those values, resolves sub-dependencies if there are any, calls the function, and uses the return value.
The same pattern keeps coming back: read the function signature, understand what values are needed, resolve them, validate them, and call the function.
That reuse of the same idea makes the framework easier to reason about. Endpoints and dependencies are not two completely separate worlds. They are both built around Python callables.
Validation Comes From Pydantic
FastAPI’s validation is closely connected to Pydantic.
from pydantic import BaseModel
class UserCreate(BaseModel):
name: str
age: int
@app.post("/users")
def create_user(user: UserCreate):
return user
Here FastAPI understands that user should come from the request body and should match the UserCreate model.
If the client sends:
{
"name": "Aman",
"age": 22
}
the request is valid. If the client sends:
{
"name": "Aman",
"age": "twenty two"
}
FastAPI returns a validation error before the endpoint runs.
FastAPI itself is not manually checking every field. It connects the request body to the Pydantic model, and Pydantic handles parsing and validation.
This is why models and type hints matter so much in FastAPI. The same information helps with validation, serialization, documentation, and editor support.
Response Models Use The Same Metadata Idea
FastAPI also uses models on the response side.
class UserOut(BaseModel):
name: str
@app.get("/users", response_model=list[UserOut])
def get_users():
return [
{"name": "Aman", "password": "secret"}
]
The endpoint returns a password field, but the response model only allows name. FastAPI can filter the response and return only what the response model defines.
This is useful because API responses need boundaries. It is easy to accidentally return extra fields, especially when working with ORM objects or internal dictionaries. Response models help define what should actually leave the server.
The same metadata is again used for more than one thing: response validation, serialization, and OpenAPI documentation.
Why The Docs Are Automatic
The /docs page is one of the first things people notice in FastAPI. You write a few routes, start the server, and Swagger UI is already available.
After looking at the internal structure, this feels less surprising. FastAPI already knows the paths, methods, parameters, request body models, response models, status codes, tags, descriptions, and security schemes. It needs most of this information to handle requests anyway.
So FastAPI can generate an OpenAPI schema from the route metadata. Swagger UI and ReDoc then use that schema.
The useful part is that documentation is not a totally separate system. The docs come from the same metadata used for request handling and validation. This does not automatically make the API design good, but it does keep the documentation closer to the actual code.
Sync And Async Fit Into The Same Structure
FastAPI supports both sync and async endpoints:
@app.get("/sync")
def sync_route():
return {"type": "sync"}
@app.get("/async")
async def async_route():
return {"type": "async"}
Internally, FastAPI checks whether the endpoint is sync or async. If it is async, FastAPI awaits it. If it is sync, FastAPI runs it in a threadpool so it does not directly block the event loop.
This is a practical design choice. FastAPI is built on ASGI, so async support is natural, but it does not force every route to be async. You can start with normal def functions and use async def where it actually makes sense.
My Mental Model Of FastAPI
After going through the internals, this is how I think about FastAPI:
FastAPI App
|
|-- Starlette foundation
| |-- ASGI
| |-- request/response
| |-- routing base
| |-- middleware
|
|-- Route layer
| |-- decorators like @app.get
| |-- route objects
| |-- endpoint metadata
|
|-- Signature inspection layer
| |-- reads function parameters
| |-- understands type hints
| |-- detects Query, Path, Body, Depends
|
|-- Dependency layer
| |-- builds dependency graph
| |-- solves dependencies per request
|
|-- Validation layer
| |-- uses Pydantic
| |-- validates path, query, body, header and cookie data
|
|-- Response layer
| |-- serializes return values
| |-- applies response models
|
|-- OpenAPI layer
|-- generates schema
|-- powers Swagger UI and ReDoc
This is not the repo structure. This is the framework structure.
The repo structure tells us where files live. This mental model tells us how FastAPI works.
Closing Thoughts
The biggest thing I took away from reading FastAPI is that the framework has a clear center: Python function signatures become API behaviour.
Once that clicked, the rest of the design made more sense. Routes, parameters, validation, dependencies, response models, and docs all come back to the endpoint function. FastAPI is not asking you to repeat the same information in multiple places. It reads the function carefully and builds the API layer around it.
This also connected with something I have been thinking about while building Quater. In FastAPI, a Python function becomes an API endpoint. With Quater, I am thinking in a similar direction, but for agent-facing applications: the same function should be understandable as an MCP tool, a CLI action, or an HTTP surface without the developer having to define everything again and again.
That is the part I found most interesting. The function stays at the center, and the framework uses its signature and metadata to expose it in different ways.
Reading FastAPI’s internals made me appreciate this design more. It is not magic, but it is carefully structured. Making a framework powerful is one thing; making it feel simple from the outside is much harder.
FastAPI does that really well.