Build a FastAPI REST API in Python: A Step‑by‑Step Tutorial

Learn FastAPI by building a clean, tested Python REST API with validation, docs, CORS, and production tips—step by step.

ASOasis
8 min read
Build a FastAPI REST API in Python: A Step‑by‑Step Tutorial

Image used for representation purposes only.

Overview

FastAPI is a modern, high‑performance web framework for building APIs with Python 3.8+. It is built on top of Starlette and Pydantic, giving you automatic data validation, type‑hinted developer ergonomics, and auto‑generated OpenAPI docs out of the box. In this tutorial you will build a clean, well‑structured REST API from scratch, learn the core concepts (path operations, models, validation, dependencies), add CORS and testing, and see how to grow the project toward production.

What you’ll build

We’ll implement a simple, extensible Todo API with full CRUD, query filtering, validation, and tests. You can run it locally with hot‑reload and explore the interactive docs at /docs and /redoc.

Prerequisites

  • Python 3.10+ recommended
  • Basic familiarity with virtual environments and typing
  • curl or HTTP client (Insomnia, Postman) for manual testing

Setup

Create and activate a virtual environment, then install FastAPI and Uvicorn (the ASGI server):

python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install "fastapi[standard]" uvicorn

The extra [standard] installs useful optional dependencies (e.g., pydantic, starlette clients). If your shell expands brackets, keep the quotes.

Project structure

Use a small but scalable layout:

fastapi-todo/
  app/
    __init__.py
    main.py
    schemas.py
    deps.py
  tests/
    test_todos.py
  • main.py: application, routers, and handlers
  • schemas.py: Pydantic models for requests/responses
  • deps.py: reusable dependencies (e.g., pagination)

Your first endpoint

Create app/main.py with a minimal application to verify everything works.

# app/main.py
from fastapi import FastAPI

app = FastAPI(title="Todo API", version="1.0.0")

@app.get("/health")
async def health():
    return {"status": "ok"}

Run the server:

uvicorn app.main:app --reload

Visit http://127.0.0.1:8000/docs for interactive documentation.

Data models (Pydantic)

Define request/response shapes in app/schemas.py. FastAPI uses Pydantic for validation and serialization, so you get clear errors and typed objects automatically.

# app/schemas.py
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field

class TodoIn(BaseModel):
    title: str = Field(..., min_length=1, max_length=100)
    done: bool = False

class TodoUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=100)
    done: Optional[bool] = None

class Todo(TodoIn):
    id: int
    created_at: datetime

Notes:

  • Field lets you enforce constraints and enrich OpenAPI docs (descriptions, examples, regex, etc.).
  • Separate “In”, “Update”, and “Out” models keep inputs/outputs explicit and stable.

Building CRUD routes

Implement the core endpoints in app/main.py. We’ll start with an in‑memory store (a dict) to keep focus on FastAPI mechanics. You can swap this for a database later without touching the API contract.

# app/main.py
from datetime import datetime
from typing import Dict, List, Optional

from fastapi import Body, Depends, FastAPI, HTTPException, Path, Query, status
from pydantic import BaseModel

from .schemas import Todo, TodoIn, TodoUpdate

app = FastAPI(title="Todo API", version="1.0.0")

# In-memory store for tutorial purposes only
DB: Dict[int, Todo] = {}
COUNTER = 0

# Dependency for simple pagination
def pagination(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100)):
    return {"skip": skip, "limit": limit}

@app.post("/todos", response_model=Todo, status_code=status.HTTP_201_CREATED)
def create_todo(payload: TodoIn = Body(..., example={"title": "Write tutorial", "done": False})):
    global COUNTER
    COUNTER += 1
    item = Todo(id=COUNTER, created_at=datetime.utcnow(), **payload.model_dump())
    DB[item.id] = item
    return item

@app.get("/todos", response_model=List[Todo])
def list_todos(done: Optional[bool] = Query(None), pg: dict = Depends(pagination)):
    items = list(DB.values())
    if done is not None:
        items = [i for i in items if i.done is done]
    start = pg["skip"]
    end = start + pg["limit"]
    return items[start:end]

@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int = Path(..., ge=1)):
    item = DB.get(todo_id)
    if not item:
        raise HTTPException(status_code=404, detail="Todo not found")
    return item

@app.patch("/todos/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, patch: TodoUpdate):
    stored = DB.get(todo_id)
    if not stored:
        raise HTTPException(404, "Todo not found")
    data = patch.model_dump(exclude_unset=True)
    updated = stored.model_copy(update=data)
    DB[todo_id] = updated
    return updated

@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int):
    if todo_id not in DB:
        raise HTTPException(404, "Todo not found")
    del DB[todo_id]
    return None

Key takeaways:

  • model_dump/model_copy give safe, explicit transformations.
  • response_model guarantees your JSON stays in sync with your types and only returns documented fields.
  • Dependencies (Depends) are how you share cross‑cutting concerns such as pagination, DB sessions, auth, or settings.

Try it out

With the server running, exercise the API:

# Create
curl -s -X POST http://127.0.0.1:8000/todos \
  -H 'content-type: application/json' \
  -d '{"title":"Write FastAPI tutorial"}' | jq

# List
curl -s http://127.0.0.1:8000/todos | jq

# Filter
curl -s 'http://127.0.0.1:8000/todos?done=false' | jq

# Update (mark done)
curl -s -X PATCH http://127.0.0.1:8000/todos/1 \
  -H 'content-type: application/json' \
  -d '{"done": true}' | jq

# Delete
curl -i -X DELETE http://127.0.0.1:8000/todos/1

Or open http://127.0.0.1:8000/docs for an interactive Swagger UI powered by your OpenAPI schema.

Request validation and error handling

Try sending invalid data like an empty title or a negative ID. FastAPI returns a 422 response describing exactly which field failed and why. These validation errors are consistent and machine‑readable, which simplifies client development.

You can centralize custom errors with exception handlers:

from fastapi import Request
from fastapi.responses import JSONResponse

class AppError(Exception):
    def __init__(self, message: str, code: str = "app_error"):
        self.message = message
        self.code = code

@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError):
    return JSONResponse(status_code=400, content={"error": exc.code, "message": exc.message})

CORS and middleware

If your frontend runs on a different origin, enable CORS:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "https://your-frontend.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Middlewares are also great for request IDs, logging, or timing metrics.

Testing with pytest

FastAPI ships with an ergonomic test client. Create tests/test_todos.py:

# tests/test_todos.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_and_list():
    r = client.post("/todos", json={"title": "Test"})
    assert r.status_code == 201
    data = r.json()
    assert data["title"] == "Test"
    # List includes created item
    r = client.get("/todos")
    assert r.status_code == 200
    assert any(todo["id"] == data["id"] for todo in r.json())

def test_update_and_delete():
    created = client.post("/todos", json={"title": "Delete me"}).json()
    tid = created["id"]
    r = client.patch(f"/todos/{tid}", json={"done": True})
    assert r.status_code == 200 and r.json()["done"] is True
    r = client.delete(f"/todos/{tid}")
    assert r.status_code == 204
    r = client.get(f"/todos/{tid}")
    assert r.status_code == 404

Run tests:

pytest -q

Dependencies: the FastAPI superpower

The Depends system is the backbone of clean architecture in FastAPI. Typical uses:

  • Database session per request
  • Auth/JWT extraction and user resolution
  • Settings injection (12‑factor)
  • Pagination and filtering

Example settings dependency using environment variables:

# app/deps.py
from functools import lru_cache
from pydantic import BaseModel
import os

class Settings(BaseModel):
    debug: bool = bool(int(os.getenv("DEBUG", "0")))
    app_name: str = os.getenv("APP_NAME", "Todo API")

@lru_cache
def get_settings() -> Settings:
    return Settings()

Use it in your routes:

from .deps import get_settings
from fastapi import Depends

@app.get("/config")
def read_config(st=Depends(get_settings)):
    return st.model_dump()

Adding a database (outline)

The in‑memory store is great for learning, but real apps need persistence. You have two popular paths:

  • SQLAlchemy 2.x (AsyncSession) with PostgreSQL or SQLite
  • SQLModel (built on SQLAlchemy + Pydantic) for concise models

A minimal SQLAlchemy async setup (outline only):

# db.py (outline)
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base

DATABASE_URL = "sqlite+aiosqlite:///./app.db"
engine = create_async_engine(DATABASE_URL, future=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()

async def get_session():
    async with AsyncSessionLocal() as session:
        yield session

Then inject get_session with Depends in your routes and use SQLAlchemy ORM models for CRUD. Keep the Pydantic schemas as your public contract.

Authentication (quick start)

For private APIs, start with OAuth2PasswordBearer or a simple bearer token. FastAPI has helpers in fastapi.security. You can implement login to return a JWT, then protect routes with a dependency that verifies and decodes the token, attaching the current user to the request context.

Production tips

  • Use an ASGI server optimized for production, e.g., uvicorn workers via gunicorn:
    • gunicorn -k uvicorn.workers.UvicornWorker -w 4 app.main:app
  • Set keep‑alive and timeout values for your load balancer; enable HTTP/2 if available.
  • Configure structured logging (JSON) and capture request IDs.
  • Add a /health and /ready endpoint for liveness/readiness probes.
  • Version your API using APIRouter prefixes, e.g., /v1.
  • Pin dependencies and enable CI to run your pytest suite on each commit.

Troubleshooting

  • 422 errors: inspect the detail field for the exact validation error; it’s usually a schema mismatch.
  • Hanging requests when using sync DB calls in async routes: either switch to AsyncSession or run blocking work in a threadpool (fastapi.concurrency.run_in_threadpool).
  • CORS errors in browsers: verify allow_origins and that your preflight (OPTIONS) responses include the correct headers.

Where to go next

  • Replace the in‑memory DB with SQLAlchemy or SQLModel
  • Add JWT auth and role‑based permissions
  • Implement rate limiting and request metrics
  • Generate a typed SDK from your OpenAPI schema for frontend or other services

Complete, minimal app/main.py

Here’s the final in‑memory version you can copy‑paste to start experimenting:

from datetime import datetime
from typing import Dict, List, Optional

from fastapi import Body, Depends, FastAPI, HTTPException, Path, Query, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

app = FastAPI(title="Todo API", version="1.0.0")

# CORS (adjust origins as needed)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

class TodoIn(BaseModel):
    title: str = Field(..., min_length=1, max_length=100)
    done: bool = False

class TodoUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=100)
    done: Optional[bool] = None

class Todo(TodoIn):
    id: int
    created_at: datetime

DB: Dict[int, Todo] = {}
COUNTER = 0

def pagination(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100)):
    return {"skip": skip, "limit": limit}

@app.get("/health")
async def health():
    return {"status": "ok"}

@app.post("/todos", response_model=Todo, status_code=status.HTTP_201_CREATED)
def create_todo(payload: TodoIn = Body(...)):
    global COUNTER
    COUNTER += 1
    item = Todo(id=COUNTER, created_at=datetime.utcnow(), **payload.model_dump())
    DB[item.id] = item
    return item

@app.get("/todos", response_model=List[Todo])
def list_todos(done: Optional[bool] = Query(None), pg: dict = Depends(pagination)):
    items = list(DB.values())
    if done is not None:
        items = [i for i in items if i.done is done]
    start, end = pg["skip"], pg["skip"] + pg["limit"]
    return items[start:end]

@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int = Path(..., ge=1)):
    item = DB.get(todo_id)
    if not item:
        raise HTTPException(404, "Todo not found")
    return item

@app.patch("/todos/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, patch: TodoUpdate):
    stored = DB.get(todo_id)
    if not stored:
        raise HTTPException(404, "Todo not found")
    data = patch.model_dump(exclude_unset=True)
    updated = stored.model_copy(update=data)
    DB[todo_id] = updated
    return updated

@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int):
    if todo_id not in DB:
        raise HTTPException(404, "Todo not found")
    del DB[todo_id]
    return None

With this foundation you can move quickly to a database‑backed, authenticated API while preserving strong typing and clear contracts. FastAPI’s combination of speed, developer experience, and standards‑compliant OpenAPI makes it an excellent choice for modern Python backend services.

Related Posts