01 · What FastAPI Is: ASGI, Starlette, Pydantic and One Request¶
FastAPI is a Python framework for building HTTP APIs. You write ordinary functions, add type hints to their parameters, and FastAPI uses those hints for three jobs at once:
- Parsing and converting incoming data (the string
"42"in a URL becomes the integer42). - Validating it, and answering with a structured
422error when it's wrong. - Documenting it, by generating an OpenAPI schema and an interactive docs page.
That's the whole pitch, and it's why the framework feels small. Most of what you'll learn in this course is how those three jobs work in detail, and what FastAPI deliberately leaves to you: database access, background job queues, deployment and security policy.
The three layers¶
FastAPI is not one big library. It's a thin layer that joins two others:
| Layer | What it does | You notice it when… |
|---|---|---|
| ASGI server (Uvicorn) | Owns the socket, speaks HTTP/1.1 or WebSocket, turns bytes into an event dictionary | you start the app, tune workers, sit behind a proxy |
| Starlette | Routing, request/response objects, middleware, WebSockets, background tasks, test client | you add middleware, stream a response, mount a sub-app |
| Pydantic | Data validation and serialization from type hints | you define models, write validators, read a 422 error |
| FastAPI | Reads your function signatures, wires Starlette and Pydantic together, dependency injection, OpenAPI | you write @app.get(...), Depends(...) |
You can confirm the layering yourself. FastAPI is literally a subclass of Starlette:
import fastapi, starlette, pydantic
from fastapi import FastAPI
app = FastAPI()
print("versions:", fastapi.__version__, starlette.__version__, pydantic.VERSION)
print("MRO:", [c.__name__ for c in type(app).__mro__][:3])
Output from the run used for this lesson:
Your version numbers will differ — FastAPI releases often and still uses 0.x numbering,
so pin versions in real projects (Level 4 lesson 9 covers upgrading).
ASGI without any framework¶
To understand what FastAPI sits on, write an ASGI app by hand. ASGI (Asynchronous Server
Gateway Interface) is a specification: an application is an async callable that takes
three arguments.
# raw_asgi.py — a complete ASGI application with no framework at all.
async def app(scope, receive, send):
if scope["type"] != "http":
return
body = f"{scope['method']} {scope['path']}\n".encode()
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": body})
scopeis a dict describing the connection:type("http","websocket"or"lifespan"),method,path,query_string(bytes),headers(a list of byte pairs), client and server addresses.receiveis an awaitable that yields events from the client, such as chunks of the request body.sendis an awaitable you call with events going back: first the status line and headers, then one or more body chunks.
Run it with Uvicorn and send it a request:
HTTP/1.1 200 OK
date: Thu, 08 Oct 2026 16:12:29 GMT
server: uvicorn
content-type: text/plain
transfer-encoding: chunked
GET /hello
Notice what you didn't get: no query-string parsing (?x=1 is still raw bytes in
scope["query_string"]), no JSON, no routing, no error handling. Everything FastAPI does
is built on top of exactly this interface.
The same idea, in FastAPI¶
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/books/{book_id}")
def read_book(book_id: int, q: str | None = None):
return {"book_id": book_id, "q": q}
From the signature alone FastAPI decided that:
book_idappears in the path template, so it's a path parameter, and it must be anint.qdoesn't appear in the path and has a simple type, so it's a query parameter; because it has a default ofNoneit's optional.- The return value is a dict, so it gets serialized to JSON.
You can exercise it without starting a server, using the test client (more in Level 2 lesson 8):
from fastapi.testclient import TestClient
from main import app
c = TestClient(app)
r = c.get("/books/42?q=dune"); print(r.status_code, r.json())
r = c.get("/books/forty-two"); print(r.status_code, r.json())
200 {'book_id': 42, 'q': 'dune'}
422 {'detail': [{'type': 'int_parsing', 'loc': ['path', 'book_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'forty-two'}]}
That 422 response is generated entirely by Pydantic. loc tells the client where the
problem was (path → book_id), type is a stable machine-readable code, and input
echoes what was received. You wrote no validation code.
A warning you may see
On the versions used here, importing the test client printed
StarletteDeprecationWarning: Using httpx with starlette.testclient is deprecated; install httpx2 instead.
Installing the httpx2 package made the warning go away. Older Starlette versions
don't print it. If your versions differ, follow whatever the warning text says.
Worked example: what the framework registered¶
The app object keeps a list of routes. Printing them shows that FastAPI added four routes of its own:
[('Route', '/openapi.json'), ('Route', '/docs'), ('Route', '/docs/oauth2-redirect'), ('Route', '/redoc'), ('APIRoute', '/books/{book_id}')]
['/books/{book_id}']
/openapi.jsonserves the machine-readable description of your API./docsserves Swagger UI, an interactive page that reads/openapi.json./redocis an alternative read-only documentation page.- Your endpoint is an
APIRoute, FastAPI's subclass of Starlette'sRoutethat knows about parameters, dependencies and response models.
The built-in routes are plain Starlette Routes, which is why they don't appear in the
OpenAPI paths themselves.
How It Actually Works¶
When you decorate a function with @app.get("/books/{book_id}"), FastAPI does most of its
work once, at import time, not on every request:
- It calls
inspect.signature()on your function and walks each parameter. - For each parameter it decides where the value comes from. The rules, in order: a name
in the path template → path; a Pydantic model or something marked
Body()→ JSON body; anything declared withDepends()→ a dependency; a simple scalar type → query string. Explicit markers (Query(),Header(),Cookie(),Form(),File()) override the defaults. - It builds a dependant tree, a description of everything this endpoint needs, and Pydantic validators for each parameter.
- It wraps your function in a Starlette-compatible request handler and adds an
APIRouteto the router.
Then, per request:
- Uvicorn parses the HTTP bytes and calls
app(scope, receive, send). - Starlette's middleware stack runs (error handling, any middleware you've added), then
the router compares
scope["path"]against each route's compiled regex in order. - The matching
APIRoutecollects raw values: path params from the regex match, query params fromscope["query_string"], headers, cookies, and — if the endpoint needs a body — awaitsreceive()until the whole body has arrived and parses it. - Everything is validated with Pydantic in one pass. If anything fails, all errors are
collected and FastAPI raises
RequestValidationError, which its default handler turns into the422you saw. - Your function is called. If it's
async defit is awaited on the event loop; if it's a plaindef, FastAPI runs it in a thread pool so it can't block the loop (Level 2 lesson 3 explains why this matters). - The return value is converted to JSON-compatible data (using a response model if you
declared one) and wrapped in a
JSONResponse, which callssend()twice — headers, then body — just like the raw ASGI app.
The OpenAPI schema is generated lazily the first time something calls app.openapi() and
then cached on the app object.
Where FastAPI fits, and where it doesn't¶
FastAPI is a good fit for JSON APIs, internal services, ML model serving, and backends for single-page or mobile apps. It is a weaker fit when you want a batteries-included web application with server-rendered pages, an admin back office and an ORM chosen for you — that's the space Django occupies. FastAPI can render templates (it bundles Jinja2 support from Starlette), but it's not its focus.
Things FastAPI doesn't include, which you'll add in later lessons:
- a database layer (we use SQLAlchemy 2.x in Level 2);
- migrations (Alembic);
- user accounts and password storage (built by hand in Level 3, using the security utilities FastAPI does provide);
- a job queue for work that outlives a request (Level 4 lesson 7).
Common mistakes¶
- Thinking type hints are only documentation. In FastAPI they change behaviour.
Changing
book_id: inttobook_id: strchanges what's accepted and what the docs say. - Confusing the framework with the server.
FastAPI()creates an ASGI app; it doesn't listen on a port. Something else (Uvicorn, usually viafastapi devorfastapi run) does. - Doing heavy work at request time that could be done at import time — or the reverse: opening a database connection at import time, which breaks tests and multi-worker deployments. Level 3 lesson 5 covers the lifespan hook for this.
- Ignoring the 0.x version. Minor releases have changed defaults before. Pin
fastapi==X.Y.Zin production and read release notes when upgrading.
Exercise¶
- Save the raw ASGI app above and run it with Uvicorn. Change it so that it returns JSON
containing the raw
query_stringand the list of header names fromscope. What type are the header names? - Write the FastAPI version of
/books/{book_id}and use the test client to send/books/3.5,/books/-1and/books/007. Record which succeed and whatbook_idcomes back. Can you explain007? - Add a second parameter
limit: int = 10and look atapp.openapi()["paths"]to see how it's described. Which fields in the schema came from your default value?