Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

How to Build REST APIs with FastAPI: A Practical Guide (2026)

If you have ever built an API with Flask or Django REST Framework and found yourself writing the same validation, serialization, and documentation code by hand, FastAPI will feel like a cheat code. FastAPI is a modern, high-performance Python framework for building REST APIs, built on Starlette for the web layer and Pydantic for data validation — and it generates interactive API documentation for you automatically.

In this practical guide, you will build a complete FastAPI REST API from scratch: installation, CRUD endpoints, Pydantic validation, query parameters with pagination, proper error handling, response models, and dependency injection with Depends. By the end you will have a clean, working tasks API you can extend into a real project.

Verified and updated: 3 October 2026.

What you need before you start

You need Python 3.10 or newer and a terminal. The official FastAPI documentation now recommends uv for setup, but pip inside a virtual environment works exactly the same. Create a project folder and isolate your dependencies first — never install packages globally for a project.

mkdir fastapi-tasks-api
cd fastapi-tasks-api
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate

Now install FastAPI with its standard extras. The [standard] extra pulls in the fastapi CLI (which gives you the fastapi dev command), Uvicorn, and other useful tooling in one shot:

pip install "fastapi[standard]"

That is the current recommendation straight from the official FastAPI installation guide — quoting the extra ("fastapi[standard]") keeps it working in every shell.

Your first FastAPI endpoint

Create a file called main.py and add this:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

Run the development server:

fastapi dev main.py

The fastapi dev command detects the FastAPI app in your file automatically and starts a Uvicorn server with auto-reload enabled. Open http://127.0.0.1:8000 and you will see your JSON response. Then visit http://127.0.0.1:8000/docs — FastAPI has already generated interactive Swagger UI documentation for your endpoint. No plugins, no config. An alternative ReDoc view lives at /redoc.

Note the decorators: @app.get("/") maps the GET HTTP method and the / path to your function. Returning a dict or list is enough — FastAPI serializes it to JSON for you.

Defining data models with Pydantic

Real APIs receive data, and you never want to trust it. FastAPI uses Pydantic models for request validation: declare the shape of your data with Python type hints, and FastAPI validates every incoming request against them automatically, returning a clear 422 error for anything invalid.

You are building a tasks API, so define what a task looks like:

from pydantic import BaseModel, Field

class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str | None = None
    priority: str = "medium"

class Task(TaskCreate):
    id: int
    done: bool = False

Splitting create and response models is a habit worth forming early: the client should never decide its own id or timestamps — the server owns those. The Field() constraints (min_length, max_length) are enforced on every request without you writing a single if statement.

Building CRUD endpoints in FastAPI

Wire the models up to route functions. For clarity, keep tasks in an in-memory dict for now — you will learn a cleaner project structure further down:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str | None = None
    priority: str = "medium"

class Task(TaskCreate):
    id: int
    done: bool = False

tasks: dict[int, Task] = {}
next_id = 1

@app.post("/tasks", response_model=Task, status_code=201)
def create_task(task: TaskCreate):
    global next_id
    new_task = Task(id=next_id, **task.model_dump())
    tasks[next_id] = new_task
    next_id += 1
    return new_task

@app.get("/tasks", response_model=list[Task])
def list_tasks():
    return list(tasks.values())

@app.get("/tasks/{task_id}", response_model=Task)
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]

@app.put("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, task: TaskCreate):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    updated = Task(id=task_id, **task.model_dump())
    tasks[task_id] = updated
    return updated

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="Task not found")
    del tasks[task_id]

Three details matter here. First, {task_id} in the path is a path parameter — the task_id: int type hint tells FastAPI to validate and convert it for you. Second, response_model=Task filters the response through the model, so stray internal fields never leak to clients and the docs stay accurate. Third, raise HTTPException(status_code=404, ...) is the idiomatic way to return errors; FastAPI converts it into a proper JSON error response.

Adding pagination with query parameters

Listing every record at once breaks the moment your dataset grows. FastAPI turns function parameters with defaults into query parameters automatically, so pagination is a few lines:

@app.get("/tasks", response_model=list[Task])
def list_tasks(skip: int = 0, limit: int = 20, done: bool | None = None):
    result = list(tasks.values())
    if done is not None:
        result = [t for t in result if t.done == done]
    return result[skip : skip + limit]

Now GET /tasks?skip=20&limit=10 pages through results and GET /tasks?done=true filters them. Because the parameters are typed, passing limit=abc returns a 422 validation error instead of crashing your code — and the query parameters show up in your auto-generated docs with their defaults.

Dependency injection with Depends

Depends is FastAPI’s superpower for real projects. Instead of repeating setup logic — database sessions, authentication, shared query parameters — you write it once as a function and inject it into any endpoint. FastAPI resolves dependencies before your route runs, caches each dependency’s result per request, and supports yield for setup-and-teardown (perfect for database sessions):

from fastapi import Depends

def get_db():
    db = open_fake_session()
    try:
        yield db
    finally:
        db.close()

@app.get("/tasks")
def list_tasks(db=Depends(get_db)):
    return db.query_tasks()

Everything before yield runs before the request; everything after runs after the response is sent, even if the endpoint raised an exception. The same pattern handles auth — a dependency that checks a token and raises a 401 stops unauthenticated requests before they ever reach your business logic. It also makes testing far easier: you override dependencies with test doubles via app.dependency_overrides instead of patching globals.

Structuring a FastAPI project that grows

A single main.py stops being fun around the fifth endpoint. For a project that survives contact with reality, split it up:

fastapi-tasks-api/
├── app/
│   ├── __init__.py
│   ├── main.py        # app instance, middleware, router includes
│   ├── models.py      # Pydantic schemas (TaskCreate, Task, ...)
│   ├── routers/
│   │   └── tasks.py   # APIRouter with the /tasks endpoints
│   └── deps.py        # shared dependencies (get_db, auth, ...)
└── tests/
    └── test_tasks.py

Use APIRouter to keep endpoints in their own module, then mount it in main.py:

# app/routers/tasks.py
from fastapi import APIRouter

router = APIRouter(prefix="/tasks", tags=["tasks"])

@router.get("")
def list_tasks():
    ...
# app/main.py
from fastapi import FastAPI
from app.routers import tasks

app = FastAPI(title="Tasks API")
app.include_router(tasks.router)

The prefix keeps paths DRY, and tags groups endpoints into named sections in your Swagger docs automatically. API versioning follows the same pattern: APIRouter(prefix="/api/v1") for each version.

Testing your endpoints

FastAPI ships with TestClient (built on httpx), so you can test endpoints without running a server. Install pytest and httpx, then write tests that hit your real routes:

pip install pytest httpx
# tests/test_tasks.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_and_get_task():
    response = client.post("/tasks", json={"title": "Write tests"})
    assert response.status_code == 201
    task_id = response.json()["id"]

    response = client.get(f"/tasks/{task_id}")
    assert response.status_code == 200
    assert response.json()["title"] == "Write tests"

def test_missing_task_returns_404():
    response = client.get("/tasks/9999")
    assert response.status_code == 404

Run them with pytest. Because TestClient drives the actual ASGI app in-process, these tests exercise routing, validation, and error handling — not just your functions.

Common mistakes to avoid

  • Blocking the event loop. If you use async def endpoints, never call blocking libraries like requests or time.sleep() inside them — they freeze the entire event loop. Use httpx.AsyncClient and asyncio.sleep() instead, or keep the endpoint as plain def and let FastAPI run it in a thread pool.
  • Skipping response_model. Without it, whatever your function returns goes straight to the client — including fields you meant to keep internal.
  • Letting clients set server-owned fields. Separate create schemas from response schemas so id, timestamps, and computed fields stay under your control.
  • Deep URL nesting. /users/3/projects/9/tasks gets painful fast. Two levels of nesting is the practical ceiling — beyond that, use a filter like /tasks?project_id=9.

Wrapping up

You now have a complete FastAPI REST API pattern: typed routes, Pydantic validation, paginated listings, honest error responses, dependency injection, a project layout that scales, and tests that prove it works. The interactive docs at /docs were generated from the code you already wrote — that is the FastAPI payoff: the type hints are the documentation.

Where to go next: add a real database with SQLAlchemy and Alembic, protect endpoints with OAuth2 or API keys via dependencies, and containerize the app with Docker and Uvicorn behind Nginx when you are ready to deploy.

Further Reading & References

Leave a Comment