
Building a REST API with FastAPI: A Step-by-Step Guide
FastAPI has become the default choice for new Python APIs, and the reason is easy to see once you use it: you describe your data with type hints, and FastAPI turns those hints into request validation, response serialization, and interactive documentation, all without extra code. There's very little ceremony between "I need an endpoint" and a working, documented endpoint.
This guide builds a complete task-tracker API from an empty folder. Step by step, you'll set up the project, define Pydantic models, implement create, read, update, and delete endpoints with proper status codes, add filtering and pagination, use dependencies to remove repetition, split routes into a router, and write tests. By the end you'll have a small but properly structured codebase you can grow into a real service.
What We're Building
A JSON API for tasks with these endpoints:
| Method | Path | Purpose |
|---|---|---|
GET | /tasks/ | List tasks, with filtering and pagination |
POST | /tasks/ | Create a task |
GET | /tasks/{task_id} | Get one task |
PATCH | /tasks/{task_id} | Update some fields of a task |
DELETE | /tasks/{task_id} | Delete a task |
GET | /health | Health check |
To keep the focus on FastAPI, tasks are stored in memory. The storage sits behind a small class, so swapping in a real database later only touches one file.
Step 1: Set Up the Project
Create a project folder and a virtual environment, then install FastAPI with its standard extras:
mkdir tasks-api && cd tasks-api
python -m venv .venv
source .venv/bin/activate
python -m pip install "fastapi[standard]"
The standard extra pulls in Uvicorn (the ASGI server that runs your app), the fastapi command-line tool, and a few other useful packages. If you use uv, uv init followed by uv add "fastapi[standard]" does the same; see managing projects with uv.
Here's the layout we'll end up with:
tasks-api/
├── pyproject.toml
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py
│ ├── store.py
│ └── routers/
│ ├── __init__.py
│ └── tasks.py
└── tests/
├── conftest.py
└── test_tasks.py
Create the empty __init__.py files now so app and app.routers are importable packages.
Step 2: A Minimal App
Start with the smallest possible application:
# app/main.py
from fastapi import FastAPI
app = FastAPI(
title="Tasks API",
version="1.0.0",
description="A small task tracker built with FastAPI.",
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
FastAPI() creates the application, and @app.get("/health") registers a function as the handler for GET /health. Whatever the function returns is converted to JSON.
Run it with the development server:
fastapi dev app/main.py
INFO: Will watch for changes in these directories: ['/Users/maria/code/tasks-api']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [11824] using WatchFiles
INFO: Application startup complete.
fastapi dev runs Uvicorn with auto-reload, so the server restarts whenever you save a file. Check that it works:
curl http://127.0.0.1:8000/health
{ "status": "ok" }
Now open http://127.0.0.1:8000/docs in a browser. That's Swagger UI, generated from your code, where you can read about and call every endpoint. There's also ReDoc at /redoc and the raw OpenAPI schema at /openapi.json. You'll get these for free for every endpoint you add.
So you don't have to type the path every time, tell the CLI where the app lives in pyproject.toml:
# pyproject.toml
[project]
name = "tasks-api"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["fastapi[standard]"]
[tool.fastapi]
entrypoint = "app.main:app"
Now plain fastapi dev works. (If you're new to this file, Understanding pyproject.toml explains the [project] table.)
Step 3: Define the Data with Pydantic
FastAPI uses Pydantic models to describe request and response bodies. A common and very effective pattern is to define separate models for each direction, because what clients send is not the same as what the server returns:
# app/models.py
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator
class Priority(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class TaskBase(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=2000)
priority: Priority = Priority.MEDIUM
class TaskCreate(TaskBase):
pass
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=2000)
priority: Priority | None = None
done: bool | None = None
@field_validator("title", "priority", "done")
@classmethod
def reject_explicit_null(cls, value):
if value is None:
raise ValueError("may be omitted, but not set to null")
return value
class Task(TaskBase):
model_config = ConfigDict(from_attributes=True)
id: int
done: bool = False
created_at: datetime
Here's the role of each model:
TaskBaseholds the fields shared by everything: a title between 1 and 200 characters, an optional description, and a priority.Field(...)adds validation constraints that FastAPI enforces on incoming data and documents in the schema.TaskCreateis what a client sends to create a task. It has noid,done, orcreated_at, because the server controls those. A client can't sneak in its own ID.TaskUpdateis for partial updates. Every field is optional, so a client can send just{"done": true}. The validator allows a field to be omitted but rejects an explicitnullfor fields that can't be empty;descriptioncan still be cleared withnull.Taskis what the API returns.from_attributes=Truelets Pydantic build it from any object with matching attributes, such as an ORM row, which you'll want when you add a database.
Priority is a StrEnum, so it serializes as a plain string and the docs show the allowed values. See enums in Python for more on why enums beat magic strings, and data validation with Pydantic for the full range of what models can do.
Step 4: A Storage Layer
The endpoints shouldn't know or care where tasks live. Put storage behind a class with a small interface:
# app/store.py
from datetime import UTC, datetime
from itertools import count
from app.models import Task, TaskCreate, TaskUpdate
class TaskStore:
"""In-memory storage. Swap for a database-backed version later."""
def __init__(self) -> None:
self._tasks: dict[int, Task] = {}
self._ids = count(1)
def list_all(
self, *, done: bool | None = None, offset: int = 0, limit: int = 20
) -> list[Task]:
tasks = list(self._tasks.values())
if done is not None:
tasks = [t for t in tasks if t.done == done]
return tasks[offset : offset + limit]
def get(self, task_id: int) -> Task | None:
return self._tasks.get(task_id)
def create(self, data: TaskCreate) -> Task:
task = Task(
id=next(self._ids), created_at=datetime.now(UTC), **data.model_dump()
)
self._tasks[task.id] = task
return task
def update(self, task_id: int, data: TaskUpdate) -> Task | None:
task = self._tasks.get(task_id)
if task is None:
return None
changes = data.model_dump(exclude_unset=True)
updated = task.model_copy(update=changes)
self._tasks[task_id] = updated
return updated
def delete(self, task_id: int) -> bool:
return self._tasks.pop(task_id, None) is not None
store = TaskStore()
def get_store() -> TaskStore:
return store
Two Pydantic details make updates work correctly:
model_dump(exclude_unset=True)returns only the fields the client actually sent. Without it, every field the client left out would come back asNoneand overwrite the stored value.model_copy(update=changes)creates a newTaskwith those fields replaced.
The get_store function looks trivial, but it's the hook that makes the storage replaceable, as you'll see in steps 6 and 8.
Step 5: The CRUD Endpoints
Now the endpoints themselves. Put them in a router, a group of related routes that you attach to the app:
# app/routers/tasks.py
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.models import Task, TaskCreate, TaskUpdate
from app.store import TaskStore, get_store
router = APIRouter(prefix="/tasks", tags=["tasks"])
StoreDep = Annotated[TaskStore, Depends(get_store)]
def get_task_or_404(task_id: int, store: StoreDep) -> Task:
task = store.get(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task {task_id} not found",
)
return task
@router.get("/", response_model=list[Task])
def list_tasks(
store: StoreDep,
done: bool | None = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
return store.list_all(done=done, offset=offset, limit=limit)
@router.post("/", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate, store: StoreDep):
return store.create(payload)
@router.get("/{task_id}", response_model=Task)
def read_task(task: Annotated[Task, Depends(get_task_or_404)]):
return task
@router.patch("/{task_id}", response_model=Task)
def update_task(
payload: TaskUpdate,
task: Annotated[Task, Depends(get_task_or_404)],
store: StoreDep,
):
return store.update(task.id, payload)
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task: Annotated[Task, Depends(get_task_or_404)], store: StoreDep):
store.delete(task.id)
And register the router in main.py:
# app/main.py
from fastapi import FastAPI
from app.routers import tasks
app = FastAPI(
title="Tasks API",
version="1.0.0",
description="A small task tracker built with FastAPI.",
)
app.include_router(tasks.router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
There's a lot happening in very little code. Let's go through how FastAPI interprets each function signature.
Where Each Parameter Comes From
FastAPI decides where to read each parameter from its type and position:
- Path parameters:
task_idappears in the path/{task_id}, so it's read from the URL. Itsintannotation means FastAPI converts"42"to42and rejects"abc"with a 422 error before your code runs. - Query parameters: simple types that aren't in the path, like
done,offset, andlimit, come from the query string:/tasks/?done=true&limit=10. A default value makes them optional.Query(ge=1, le=100)adds bounds, so nobody can request a million tasks at once. - Request body: a parameter typed as a Pydantic model, like
payload: TaskCreate, is read from the JSON body and validated against the model. - Dependencies: a parameter annotated with
Depends(...)gets its value by calling another function. More on that in the next step.
The Annotated[int, Query(ge=0)] form keeps the real type (int) separate from FastAPI's metadata, which is the style FastAPI recommends.
Responses and Status Codes
response_model=Tasktells FastAPI to validate and serialize the return value through theTaskmodel. This filters the output to exactly the declared fields, which protects you from accidentally leaking internal data, and documents the response shape. You can alternatively declare the return type annotation (-> Task), and FastAPI uses that the same way.status_code=201onPOSTis the correct REST response for "created".DELETEreturns204 No Contentwith an empty body. Using thestatusconstants (status.HTTP_201_CREATED) avoids magic numbers.HTTPExceptionstops the request and returns an error response. Raising it with a 404 and adetailmessage produces{"detail": "Task 99 not found"}.
Plain def or async def?
These handlers are plain def functions, and that's deliberate. FastAPI runs regular functions in a thread pool, so blocking work inside them doesn't stall the server. Use async def when the handler awaits something, such as an async database driver or an HTTP call with an async client, and never call blocking functions inside an async def handler. If you're unsure, start with def. The asyncio beginner's guide covers the underlying model.
Step 6: Dependencies Remove Repetition
Dependency injection is FastAPI's most useful feature once an API grows past a few endpoints. A dependency is just a function; FastAPI calls it for each request and passes the result into your handler. Dependencies can themselves declare parameters and dependencies.
Two are at work here:
StoreDepis anAnnotatedalias for "aTaskStoreprovided byget_store". Every handler that needs storage just declaresstore: StoreDep. When you switch to a database,get_storebecomes the function that opens a session, and no handler changes.get_task_or_404takes thetask_idpath parameter, looks up the task, and raises a 404 if it doesn't exist. The read, update, and delete handlers all declaretask: Annotated[Task, Depends(get_task_or_404)]and receive a task that's guaranteed to exist. The "not found" logic is written once instead of three times.
FastAPI caches a dependency's result within a single request, so update_task's handler and get_task_or_404 share the same store instance. The same mechanism handles authentication: a get_current_user dependency that reads a token and raises 401 is how most FastAPI apps protect endpoints, covered in authentication in FastAPI with OAuth2 and JWT.
Step 7: Try It Out
With fastapi dev running, exercise the API with curl (or from the /docs page, which is often quicker).
Create a task:
curl -X POST http://127.0.0.1:8000/tasks/ \
-H "Content-Type: application/json" \
-d '{"title": "Write the API post", "priority": "high"}'
{
"title": "Write the API post",
"description": null,
"priority": "high",
"id": 1,
"done": false,
"created_at": "2026-10-03T15:23:07.766304Z"
}
Mark it done with a partial update:
curl -X PATCH http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"done": true}'
{
"title": "Write the API post",
"description": null,
"priority": "high",
"id": 1,
"done": true,
"created_at": "2026-10-03T15:23:07.766304Z"
}
Only done changed; the title, priority, and timestamp were left alone thanks to exclude_unset=True.
Filter and paginate:
curl "http://127.0.0.1:8000/tasks/?done=true&offset=0&limit=10"
Now send something invalid, such as an empty title:
curl -X POST http://127.0.0.1:8000/tasks/ \
-H "Content-Type: application/json" \
-d '{"title": ""}'
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"input": "",
"ctx": { "min_length": 1 }
}
]
}
FastAPI returned 422 Unprocessable Content with a structured error that says exactly which field failed and why. You didn't write any of that validation code; it came from Field(min_length=1). The same happens for an unknown priority ("Input should be 'low', 'medium' or 'high'"), a limit of 500, or a non-numeric task ID in the path.
Finally, delete the task and confirm it's gone:
curl -i -X DELETE http://127.0.0.1:8000/tasks/1
curl http://127.0.0.1:8000/tasks/1
HTTP/1.1 204 No Content
...
{"detail":"Task 1 not found"}
One small thing to know: the collection routes are defined as /tasks/ with a trailing slash. A request to /tasks gets a 307 redirect to /tasks/. Most clients follow it automatically, but it's best to use the canonical URL.
Step 8: Write Tests
FastAPI's TestClient lets you call your API in-process, without starting a server. Install pytest along with httpx2, the HTTP client package that current versions of Starlette's test client are built on (if only the older httpx package is installed, the test client still works but emits a deprecation warning):
python -m pip install pytest httpx2
Tell pytest where to find the app package:
# pyproject.toml (add to the existing file)
[tool.pytest]
testpaths = ["tests"]
pythonpath = ["."]
The key trick for isolated tests is app.dependency_overrides. It replaces any dependency for the duration of a test, so each test can get its own fresh, empty store:
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.store import TaskStore, get_store
@pytest.fixture
def client():
test_store = TaskStore()
app.dependency_overrides[get_store] = lambda: test_store
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
This is the payoff of routing all storage access through get_store. In a database-backed version, the same override would point at a test database or a transaction that's rolled back after each test.
The tests read like a description of the API's behavior:
# tests/test_tasks.py
def create(client, **fields):
payload = {"title": "Test task", **fields}
response = client.post("/tasks/", json=payload)
assert response.status_code == 201
return response.json()
def test_create_task(client):
task = create(client, title="Write tests", priority="high")
assert task["id"] == 1
assert task["title"] == "Write tests"
assert task["priority"] == "high"
assert task["done"] is False
def test_create_task_validates_title(client):
response = client.post("/tasks/", json={"title": ""})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "title"]
def test_get_missing_task_returns_404(client):
response = client.get("/tasks/999")
assert response.status_code == 404
assert response.json() == {"detail": "Task 999 not found"}
def test_partial_update_only_changes_sent_fields(client):
task = create(client, title="Original", description="Keep me")
response = client.patch(f"/tasks/{task['id']}", json={"done": True})
assert response.status_code == 200
body = response.json()
assert body["done"] is True
assert body["title"] == "Original"
assert body["description"] == "Keep me"
def test_update_rejects_null_title(client):
task = create(client)
response = client.patch(f"/tasks/{task['id']}", json={"title": None})
assert response.status_code == 422
def test_filter_and_paginate(client):
for i in range(5):
create(client, title=f"Task {i}")
client.patch("/tasks/2", json={"done": True})
assert len(client.get("/tasks/", params={"done": True}).json()) == 1
page = client.get("/tasks/", params={"offset": 1, "limit": 2}).json()
assert [t["id"] for t in page] == [2, 3]
def test_delete_task(client):
task = create(client)
assert client.delete(f"/tasks/{task['id']}").status_code == 204
assert client.get(f"/tasks/{task['id']}").status_code == 404
pytest -q
....... [100%]
7 passed in 0.11s
Each test gets a clean store, so they pass in any order and IDs always start at 1. The small create helper keeps setup to one line; as the suite grows, the pytest fixtures and parametrization guide shows how to factor out more.
Step 9: Running in Production
fastapi dev is for development only: it enables auto-reload and binds to localhost. For production, use fastapi run, which disables reload and listens on all interfaces:
fastapi run --workers 4
--workers starts multiple processes to use several CPU cores. In a container you'd typically run a single process per container and let the orchestrator scale the number of containers instead; the Docker deployment guide walks through that setup.
Where to Go Next
The structure you have now scales well. The usual next steps are:
- A real database. Replace
TaskStorewith a class backed by SQLAlchemy and makeget_storeyield a session. The models'from_attributes=Truesetting means ORM objects can be returned directly. See SQLAlchemy 2.0 ORM basics. - Authentication. Add a
get_current_userdependency and declare it on the routes that need it. - Settings. Read configuration such as database URLs from environment variables with
pydantic-settings, provided through a dependency. - More routers. Each resource (users, projects, comments) gets its own module in
app/routers/and its owninclude_routercall.
If you're still deciding whether FastAPI is the right framework for a particular project, Django vs Flask vs FastAPI compares the trade-offs.
Conclusion
FastAPI lets you build a REST API by describing it. Pydantic models define what clients send and what they get back, type hints on handler parameters decide whether values come from the path, query string, or body, and constraints like Field(min_length=1) and Query(le=100) become validation and documentation at the same time. Status codes and HTTPException give clients clear, standard responses.
The habits that keep a FastAPI codebase healthy as it grows are already in this small project: separate models for input and output, storage behind a dependency, shared lookups like get_task_or_404 written once as dependencies, routes grouped in routers, and tests that swap dependencies through dependency_overrides. Start from this skeleton, and adding a database, authentication, or a dozen more endpoints is mostly a matter of repeating the same patterns.


