Type something to search...
Building a REST API with FastAPI: A Step-by-Step Guide

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:

MethodPathPurpose
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/healthHealth 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:

  • TaskBase holds 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.
  • TaskCreate is what a client sends to create a task. It has no id, done, or created_at, because the server controls those. A client can't sneak in its own ID.
  • TaskUpdate is 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 explicit null for fields that can't be empty; description can still be cleared with null.
  • Task is what the API returns. from_attributes=True lets 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 as None and overwrite the stored value.
  • model_copy(update=changes) creates a new Task with 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_id appears in the path /{task_id}, so it's read from the URL. Its int annotation means FastAPI converts "42" to 42 and rejects "abc" with a 422 error before your code runs.
  • Query parameters: simple types that aren't in the path, like done, offset, and limit, 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=Task tells FastAPI to validate and serialize the return value through the Task model. 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=201 on POST is the correct REST response for "created". DELETE returns 204 No Content with an empty body. Using the status constants (status.HTTP_201_CREATED) avoids magic numbers.
  • HTTPException stops the request and returns an error response. Raising it with a 404 and a detail message 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:

  • StoreDep is an Annotated alias for "a TaskStore provided by get_store". Every handler that needs storage just declares store: StoreDep. When you switch to a database, get_store becomes the function that opens a session, and no handler changes.
  • get_task_or_404 takes the task_id path parameter, looks up the task, and raises a 404 if it doesn't exist. The read, update, and delete handlers all declare task: 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 TaskStore with a class backed by SQLAlchemy and make get_store yield a session. The models' from_attributes=True setting means ORM objects can be returned directly. See SQLAlchemy 2.0 ORM basics.
  • Authentication. Add a get_current_user dependency 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 own include_router call.

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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading