Topic 15.5
Building an API with FastAPI
In one line
FastAPI is a Python framework for web APIs: you write ordinary functions with type hints, and it turns them into HTTP endpoints, validates path, query and body data with Pydantic, shapes the response, and generates interactive documentation. You run it with the uvicorn server.
Think of it like this
The front desk of an office. Visitors ask for things in a set way ("I would like to see room 12", "please leave this parcel"). The receptionist checks each request is complete, sends it to the right person, and hands back a clear answer or a polite "we cannot do that, here is why". A web API framework is that front desk for your Python functions: it receives HTTP requests, checks them, calls the right function, and sends the response.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Server
- A program that waits for requests and answers them.
- Framework
- A library that provides the structure of an application and calls your code at the right moments.
- Route
- A pairing of an HTTP method and a path with the function that answers it.
- Decorator
- The @something line above a function that registers or changes it.
- Path parameter
- A value inside the URL path, such as the 3 in /todos/3.
- Query parameter
- A value after the ? in a URL, such as limit=5.
- Request body
- The data sent with a POST or PUT, usually JSON.
- 422
- The status FastAPI returns when the request data fails validation.
- OpenAPI
- A standard machine-readable description of an API's endpoints and data shapes.
- uvicorn
- The server program that runs a FastAPI application and listens for HTTP connections.
Step by step
01How a request travels
uvicorn accepts the HTTP connection and gives the request to FastAPI. FastAPI finds the matching route, validates path, query and body, calls your function, then validates and serialises the return value into the response.
02A first endpoint
Install with pip install fastapi uvicorn (or uv add fastapi uvicorn). Save this as main.py. A dictionary return value becomes JSON; the default status is 200.
from fastapi import FastAPI
app = FastAPI(title="Todo API")
@app.get("/health")
def health():
return {"status": "ok"}03Start the server
uvicorn main:app means: import app from main.py. Add --reload during development to restart on every file save (never in production). The --port flag picks the port; the default is 8000.
uvicorn main:app --port 8000 --reload
# then open http://127.0.0.1:8000/docs04Path and query parameters
todo_id: int is in the path, so it is a path parameter and must be an integer. done: bool | None = None and limit: int = 10 are not in the path, so they are optional query parameters with defaults. A request for /todos?limit=many fails validation before your code runs.
@app.get("/todos/{todo_id}", response_model=Todo)
def read_todo(todo_id: int):
if todo_id not in todos:
raise HTTPException(status_code=404, detail="todo not found")
return todos[todo_id]
@app.get("/todos", response_model=list[Todo])
async def list_todos(done: bool | None = None, limit: int = 10):
items = [t for t in todos.values() if done is None or t.done == done]
return items[:limit]05Request bodies are Pydantic models
A parameter typed as a model is read from the JSON body. Two models are common: one for input (TodoIn, without an id, because the server assigns it) and one for output (Todo, with the id). response_model=Todo filters and documents the reply, and status_code=201 sets the success code.
class TodoIn(BaseModel):
title: str = Field(min_length=1, max_length=100)
done: bool = False
class Todo(TodoIn):
id: int
@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(body: TodoIn):
todo = Todo(id=len(todos) + 1, **body.model_dump())
todos[todo.id] = todo
return todo06Call it and read the answers
The terminal block in the code section shows a real run: the server was started, called with httpx, and stopped. Look at the 422 messages: the same loc, type and msg fields as a Pydantic ValidationError (Topic 15.4), with body or query added at the front so the client knows where the problem was.
07Dependencies and tests (preview)
Depends(...) lets a function declare what it needs (a database connection, the current user, the settings object from Topic 15.3), and FastAPI supplies it, once per request. In tests you replace dependencies with fakes, and fastapi.testclient.TestClient (built on httpx) calls the app in memory without a server. Phase 11 covers testing in general.
Try it yourself
- 1
Run it yourself
Save the todo API as
main.py, runpip install fastapi uvicorn, thenuvicorn main:app --reload. Openhttp://127.0.0.1:8000/docs, press Try it out onPOST /todos, and send an empty title. Read the 422 answer. - 2
Add a field
In the runnable toy add
priority: int = Field(default=1, ge=1, le=3)toTodoIn. Send{"title": "x", "priority": 9}and predict the error message before running. - 3
Add a DELETE route
In the real app add
@app.delete("/todos/{todo_id}", status_code=204)that removes the item, or raises the same 404 asread_todo. Check the new route appears in/docswithout any extra work.
Code & diagrams
Not runnable here: FastAPI and uvicorn need a real server process, which the browser cannot start. Run it on your own computer.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Todo API")
class TodoIn(BaseModel):
title: str = Field(min_length=1, max_length=100)
done: bool = False
class Todo(TodoIn):
id: int
todos: dict[int, Todo] = {}
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(body: TodoIn):
todo = Todo(id=len(todos) + 1, **body.model_dump())
todos[todo.id] = todo
return todo
@app.get("/todos/{todo_id}", response_model=Todo)
def read_todo(todo_id: int):
if todo_id not in todos:
raise HTTPException(status_code=404, detail="todo not found")
return todos[todo_id]
@app.get("/todos", response_model=list[Todo])
async def list_todos(done: bool | None = None, limit: int = 10):
items = [t for t in todos.values() if done is None or t.done == done]
return items[:limit]Captured on a real machine (Python 3.12, a recent FastAPI and uvicorn). The process id and the client port numbers differ on every run, and message wording can change slightly between versions.
# terminal 1
uvicorn main:app --port 8765
# terminal 2 (calls made with httpx; curl gives the same bodies)
GET /health -> 200 {"status":"ok"}
POST /todos {"title": "write phase 15"}
-> 201 {"title":"write phase 15","done":false,"id":1}
POST /todos {"title": "ship it", "done": true}
-> 201 {"title":"ship it","done":true,"id":2}
GET /todos/1 -> 200 {"title":"write phase 15","done":false,"id":1}
GET /todos/42 -> 404 {"detail":"todo not found"}
GET /todos?done=true -> 200 [{"title":"ship it","done":true,"id":2}]
GET /todos?limit=many -> 422 {"detail":[{"type":"int_parsing","loc":["query","limit"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"many"}]}
POST /todos {"title": ""} -> 422 {"detail":[{"type":"string_too_short","loc":["body","title"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}}]}Output
INFO: Started server process [18768]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8765 (Press CTRL+C to quit)
INFO: 127.0.0.1:57904 - "POST /todos HTTP/1.1" 201 Created
INFO: 127.0.0.1:57904 - "GET /todos/42 HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:57904 - "GET /todos?limit=many HTTP/1.1" 422 Unprocessable EntityA toy 'framework' written as a plain function (`**body.model_dump()` spreads a dictionary into keyword arguments, Topic 5.3) and called through a mock transport. It shows the real mechanism behind the 422 answer: Pydantic validation turned into an HTTP response.
Expected output
201 {'title': 'write phase 15', 'done': False, 'id': 1}
422 {'detail': [{'loc': ['body', 'title'], 'msg': 'String should have at least 1 character'}]}
422 {'detail': [{'loc': ['body', 'title'], 'msg': 'Field required'}, {'loc': ['body', 'done'], 'msg': 'Input should be a valid boolean, unable to interpret input'}]}
200 {'title': 'write phase 15', 'done': False, 'id': 1}
404 {'detail': 'todo not found'}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a mutable global as if it were a database
Run the app with several worker processes and keep the todos in a Python dictionary.
uvicorn main:app --workers 2Myth vs fact
Myth
FastAPI is only fast because it is written in a fast language.
Fact
The name is about speed of development and good performance for an async Python framework. Most time in real services goes to waiting for databases and other APIs, not to Python code.
Myth
I need async def everywhere.
Fact
No. Plain def endpoints are fine and run in a thread pool. Use async def only when everything you await inside is async-aware; one blocking call inside async def freezes the whole event loop (Topic 12.6).
Myth
Validation is my job inside each function.
Fact
Type hints and Pydantic models already validate input before your function runs. Your code deals with rules the types cannot express, like "this id must exist".
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Mind the blocking call in async def trap: calling
requests.getor a slow database driver insideasync defstops every other request. Usehttpx.AsyncClient, an async database driver, or define the endpoint with plaindef. - ▸
Production serving: run several uvicorn workers (or use gunicorn with the uvicorn worker class) behind a reverse proxy or the platform's load balancer, put real state in a database, and add a
/healthendpoint for the orchestrator (Topic 15.9). Do not use--reloadthere. - ▸
Streaming: an endpoint can return a
StreamingResponsebuilt from a generator, which is how a chat service sends tokens as the model produces them (Topic 16.5). Authentication, rate limiting and CORS are handled with dependencies and middleware; never trust a client-supplied user id without checking a token. - ▸
How
defendpoints really run: FastAPI (through Starlette and AnyIO) hands each one to a thread pool whose default limit is 40 threads. Forty slow blocking requests at once make the forty-first wait, even though the server looks idle. A CPU-heavy function is a problem either way: inasync defit blocks the event loop, and indefit fights for the GIL (Topic 12.2). Move heavy work to a process pool or a job queue, and useawait asyncio.to_thread(...)(Topic 12.7) for a blocking call inside anasync defendpoint. - ▸
Dependency injection is FastAPI's main design tool.
Depends(get_db)(usually writtendb: Annotated[Session, Depends(get_db)]) lets a function declare what it needs. A dependency thatyields runs its clean-up code after the response, which is the pattern for database sessions. In tests,app.dependency_overrides[get_db] = fake_dbswaps the real thing for a fake with no patching. Authentication, pagination parameters and the settings object (Topic 15.3) are all normally dependencies. - ▸
Modern details: put the shape in the return annotation (
def read_todo(id: int) -> Todo:), which FastAPI uses as the response model, so private fields are still filtered out. Newer FastAPI versions also ship afastapi devcommand (auto-reload) andfastapi run(production style) next to plainuvicorn. uvicorn ignores--workerswhen--reloadis on, so test multi-worker behaviour without reload. Run the app once throughTestClientin your tests so that route, validation and serialisation bugs show up without starting a server. Also remember thatlen(todos) + 1as an id breaks as soon as anything is deleted: let the database assign ids. - ▸
Flask and Django are the older, widely used alternatives. Flask is small and synchronous at heart; Django includes an admin, an ORM and templates. FastAPI is the common choice for new JSON APIs, especially around AI.
Remember this
- 1
In Topics 15.1 and 15.2 your program was the client. Now it is the server: other programs call it. A server program listens on a port, a numbered door on the computer (for example 8000), and waits for requests to arrive there. A framework handles the dull, error-prone parts: reading the request, finding which function should answer it (routing), converting and validating inputs, turning your return value into JSON, and sending proper status codes.
- 2
In FastAPI you create
app = FastAPI()and mark functions with decorators (Topic 9.6: a line starting with@above a function that registers or changes it) such as@app.get("/health")or@app.post("/todos"). The decorator says: for this method and path, call this function. Whatever the function returns (a dictionary, a list, a Pydantic model) is converted to JSON automatically. - 3
FastAPI reads your type hints to decide where each parameter comes from. A name that appears in the path (
/todos/{todo_id}) is a path parameter. A simple-typed parameter that is not in the path (limit: int = 10) is a query parameter (?limit=5). A parameter typed as a Pydantic model (body: TodoIn) is the JSON request body. It converts the types (the text "5" becomes the int 5) and validates them with Pydantic (Topic 15.4). - 4
When validation fails, FastAPI does not call your function. It answers **
422 Unprocessable Entity** with a JSON body listing each problem and its location (["body", "title"]or["query", "limit"]). To report your own errors,raise HTTPException(status_code=404, detail="todo not found"). Theresponse_model=option states the shape of the reply, so extra fields (such as a password hash) are filtered out before sending. - 5
FastAPI also builds the documentation for you. The same type hints become an OpenAPI description at
/openapi.json, and an interactive page at/docs(Swagger UI) where you can try every endpoint in the browser. That description is machine-readable too, which is how client code, test tools and AI agents learn what your API accepts. - 6
Endpoints can be plain
deforasync def(Topic 12.6: a function that can pause while it waits, so other work can run). Useasync defwhen the function awaits things that support async, such ashttpx.AsyncClientcalls to a model API; usedeffor ordinary blocking code, and FastAPI runs it in a worker thread so one slow call does not freeze the server. uvicorn is the program that listens on a port and speaks HTTP to FastAPI; you start it withuvicorn main:app(filemain.py, objectapp). Many AI products are exactly this: a FastAPI service that receives a question, calls a model, and returns or streams the answer.
Explain it without notes
How does FastAPI decide whether a function parameter is a path, query or body parameter?
What happens when a request has invalid data, and how do you return your own error?
What are response_model and the automatic /docs page, and where does the documentation come from?
When should an endpoint be def and when async def?
What does Depends do, and how does it help testing?
Practice
Write (not run here; save it in a file main.py) an endpoint GET /greet/{name} that returns {"message": "Hello, <name>!"} and accepts an optional query parameter shout (bool).
Write a model Item with name and price (above 0) and a POST /items endpoint that returns the item with status 201.
Using the runnable toy approach, write a mock-transport app function that answers GET /ping with {"pong": true} and anything else with 404.
Trade-offs
- ↔
FastAPI versus Flask or Django. FastAPI gives validation, docs and async with little code, and fits JSON APIs. Django suits full websites with an admin and database models; Flask suits small, simple services. Choose by what you are building.
- ↔
Separate input and output models versus one. Two models avoid leaking server-assigned or private fields, but add classes. A single model is shorter but easier to misuse.
Done when you can
Done when you can write a FastAPI app with GET and POST endpoints and run it with uvicorn.
Done when you can say what makes a parameter a path, query or body parameter.
Done when you can explain the 422 response and raise an
HTTPException.Done when you can describe
response_modeland where/docscomes from.Done when you know why a blocking call inside
async defis a bug.