Build a web API with FastAPI
Turn Python functions into a documented HTTP contract with validated requests, explicit status codes, and tests.
By the end, you’ll be able to…
- Map resources and operations to HTTP methods, paths, and status codes.
- Validate request and response bodies with Pydantic models.
- Test the API through its public interface with TestClient.
01HTTP is a contract
An API is a promise about methods, paths, request bodies, response bodies, and status codes. GET reads, POST creates, PUT or PATCH updates, and DELETE removes. A successful creation usually returns 201; a missing resource returns 404; invalid input returns 422. The HTTP method and status are part of the interface, not decoration added after the code works.
FastAPI turns a decorated function into an operation and generates OpenAPI documentation from the annotations. The function name and docstring should describe one resource operation. Keep transport concerns at the route boundary and call ordinary Python functions for domain work. That way the same rule can be tested without starting a server.
from fastapi import FastAPIapp = FastAPI(title="Task API", version="1.0")@app.get("/health")def health(): return {"status": "ok"}02Model the boundary with Pydantic
Pydantic models describe the shape of data entering and leaving the API. Type annotations become validation rules, and Field adds length or range constraints. A request model should contain only fields a client may send. A response model can add server-owned fields such as an id while preventing accidental internal data from leaking.
FastAPI validates a request before calling the route. A malformed body produces a structured 422 response instead of a TypeError deep in your code. Keep validation at the boundary and keep domain invariants in the domain layer. If a title must be unique, that is a rule about stored tasks, not a property of the JSON shape alone.
from pydantic import BaseModel, Fieldclass TaskIn(BaseModel): title: str = Field(min_length=1, max_length=80) done: bool = Falseclass TaskOut(TaskIn): id: inttask = TaskIn(title="Ship the API")print(task.model_dump())print(TaskOut(id=1, **task.model_dump()).model_dump()){'title': 'Ship the API', 'done': False}
{'title': 'Ship the API', 'done': False, 'id': 1}03Routes, dependencies, and errors
A route should translate HTTP into a domain operation and translate the result back. Use status_code on the decorator for a successful creation, response_model to enforce the public shape, and HTTPException for a missing resource. Avoid returning different shapes from the same endpoint; clients need a stable contract.
A dependency is a reusable function that provides something a route needs, such as configuration, a database session, or the current user. FastAPI resolves it before the route runs. For a small in-memory example, a module-level dictionary is enough to demonstrate the flow, but the route boundary remains the same when the storage becomes SQLite or PostgreSQL.
from fastapi import FastAPI, HTTPExceptionfrom models import TaskIn, TaskOutapp = FastAPI()tasks = {}next_id = 1@app.post("/tasks", status_code=201, response_model=TaskOut)def create_task(task: TaskIn): global next_id item = TaskOut(id=next_id, **task.model_dump()) tasks[item.id] = item next_id += 1 return item@app.get("/tasks/{task_id}", response_model=TaskOut)def get_task(task_id: int): if task_id not in tasks: raise HTTPException(status_code=404, detail="task not found") return tasks[task_id]04Test and ship the API
TestClient calls the application through its public HTTP interface without opening a network port. A test can assert the status code, the JSON body, and the error shape. Cover the happy path, a missing id, a validation failure, and any state change. Those checks protect the contract that clients depend on.
Run the app with Uvicorn during development, and read the generated documentation at /docs. Keep configuration such as database URLs and secrets in environment variables. Before deployment, add structured logging, a request timeout policy, and a health check. The exact host is an operational choice; the API contract is the part your tests should pin down.
from fastapi.testclient import TestClientfrom tasks import appclient = TestClient(app)def test_create_and_read_task(): created = client.post("/tasks", json={"title": "Write tests"}) assert created.status_code == 201 task_id = created.json()["id"] fetched = client.get(f"/tasks/{task_id}") assert fetched.status_code == 200 assert fetched.json()["title"] == "Write tests"def test_missing_task(): response = client.get("/tasks/999") assert response.status_code == 404The ideas worth keeping.
- Design the method, path, request shape, response shape, and status codes before implementation.
- Validate at the HTTP boundary and keep domain rules in ordinary Python code.
- Test the public API through TestClient, including missing resources and invalid bodies.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Add an update operation
Add PUT /tasks/{task_id} that accepts TaskIn, returns 404 for an unknown id, replaces the stored task while preserving its id, and returns the updated TaskOut.
Explore a worked solution
@app.put("/tasks/{task_id}", response_model=TaskOut)def update_task(task_id: int, task: TaskIn): if task_id not in tasks: raise HTTPException(status_code=404, detail="task not found") updated = TaskOut(id=task_id, **task.model_dump()) tasks[task_id] = updated return updatedThe path identifies the resource, the body supplies the new values, and the response model keeps the public shape stable. The unknown-id branch is the same contract as the read route.
Test a validation failure
Write a test that posts an empty title and expects 422. Then add a test for a successful delete returning 204.
Explore a worked solution
def test_empty_title_is_rejected(): response = client.post("/tasks", json={"title": ""}) assert response.status_code == 422def test_delete_task(): created = client.post("/tasks", json={"title": "Temporary"}) task_id = created.json()["id"] assert client.delete(f"/tasks/{task_id}").status_code == 204 assert client.get(f"/tasks/{task_id}").status_code == 404A validation failure happens before the route body runs, so it should not create a task. A delete that succeeds returns no body, and a later read proves the resource is gone.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Add an update operation
Add PUT /tasks/{task_id} that accepts TaskIn, returns 404 for an unknown id, replaces the stored task while preserving its id, and returns the updated TaskOut.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.