Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~29 min· topic 5 of 10

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.

How a request travelsdiagram
Rendering diagram…

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.

main.pywhole filepython
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.

terminalwhole filebash
uvicorn main:app --port 8000 --reload
# then open http://127.0.0.1:8000/docs

04Path 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.

main.pywhole filepython
@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.

main.pywhole filepython
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 todo

06Call 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. 1

    Run it yourself

    Save the todo API as main.py, run pip install fastapi uvicorn, then uvicorn main:app --reload. Open http://127.0.0.1:8000/docs, press Try it out on POST /todos, and send an empty title. Read the 422 answer.

  2. 2

    Add a field

    In the runnable toy add priority: int = Field(default=1, ge=1, le=3) to TodoIn. Send {"title": "x", "priority": 9} and predict the error message before running.

  3. 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 as read_todo. Check the new route appears in /docs without any extra work.

Code & diagrams

main.py: a small todo APIpython

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]
A real run: start uvicorn, call it, stop itbash

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 Entity
What a framework does for you, in a runnable toy New tab

A 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.

Sign in to run this example in your browser.

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'}
Where each parameter comes fromdiagram
Rendering diagram…

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.

terminalwhole filebash
uvicorn main:app --workers 2
terminal
$ GET /todos/1 (repeated)
── what you'll see ──
200 on one request, 404 {"detail":"todo not found"} on the next

Myth 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.get or a slow database driver inside async def stops every other request. Use httpx.AsyncClient, an async database driver, or define the endpoint with plain def.

  • ▸

    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 /health endpoint for the orchestrator (Topic 15.9). Do not use --reload there.

  • ▸

    Streaming: an endpoint can return a StreamingResponse built 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 def endpoints 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: in async def it blocks the event loop, and in def it fights for the GIL (Topic 12.2). Move heavy work to a process pool or a job queue, and use await asyncio.to_thread(...) (Topic 12.7) for a blocking call inside an async def endpoint.

  • ▸

    Dependency injection is FastAPI's main design tool. Depends(get_db) (usually written db: Annotated[Session, Depends(get_db)]) lets a function declare what it needs. A dependency that yields runs its clean-up code after the response, which is the pattern for database sessions. In tests, app.dependency_overrides[get_db] = fake_db swaps 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 a fastapi dev command (auto-reload) and fastapi run (production style) next to plain uvicorn. uvicorn ignores --workers when --reload is on, so test multi-worker behaviour without reload. Run the app once through TestClient in your tests so that route, validation and serialisation bugs show up without starting a server. Also remember that len(todos) + 1 as 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. 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. 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. 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. 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"). The response_model= option states the shape of the reply, so extra fields (such as a password hash) are filtered out before sending.

  5. 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. 6

    Endpoints can be plain def or async def (Topic 12.6: a function that can pause while it waits, so other work can run). Use async def when the function awaits things that support async, such as httpx.AsyncClient calls to a model API; use def for 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 with uvicorn main:app (file main.py, object app). 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

01

How does FastAPI decide whether a function parameter is a path, query or body parameter?

02

What happens when a request has invalid data, and how do you return your own error?

03

What are response_model and the automatic /docs page, and where does the documentation come from?

04

When should an endpoint be def and when async def?

05

What does Depends do, and how does it help testing?

Practice

01

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).

02

Write a model Item with name and price (above 0) and a POST /items endpoint that returns the item with status 201.

03

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_model and where /docs comes from.

  • Done when you know why a blocking call inside async def is a bug.