FastAPI in Practice: Build a Clean CRUD API with Validation, Dependencies, and Pagination
FastAPI is a great fit when you want a modern Python API that’s fast to build, easy to maintain, and pleasant to consume. In this hands-on guide, you’ll build a small but “real” CRUD API for todos with:
- Request/response models (validation + clean output)
- Dependency injection (shared services, auth, config)
- Pagination + filtering
- Consistent error handling
- A background task (fire-and-forget work)
The examples below are ready to run locally.
1) Setup: Create a small project
Install dependencies:
python -m venv .venv # Windows: .venv\Scripts\activate source .venv/bin/activate pip install fastapi "uvicorn[standard]" pydantic-settings
Create this structure (one file is enough for a demo, but this layout scales nicely):
app/ main.py
2) The API: models, repository, and routes
This implementation uses an in-memory repository to keep things simple (no database setup). The patterns (models, dependencies, routing) are the same when you later swap the repository for Postgres/SQLAlchemy.
from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import Dict, List, Optional from uuid import uuid4 from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Query, status from pydantic import BaseModel, Field from pydantic_settings import BaseSettings # ----------------------------- # Settings (configuration) # ----------------------------- class Settings(BaseSettings): app_name: str = "Todo API" api_key: str = "dev-secret" # override with environment variable API_KEY class Config: env_prefix = "" # Set API_KEY=... in your environment to override api_key def get_settings() -> Settings: return Settings() # ----------------------------- # Auth dependency (simple API key) # ----------------------------- def require_api_key( x_api_key: str = Header(default="", alias="X-API-Key"), settings: Settings = Depends(get_settings), ) -> None: if not x_api_key or x_api_key != settings.api_key: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key", ) # ----------------------------- # Pydantic models (validation + output) # ----------------------------- class TodoCreate(BaseModel): title: str = Field(..., min_length=1, max_length=120) description: Optional[str] = Field(default=None, max_length=1000) class TodoUpdate(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=120) description: Optional[str] = Field(default=None, max_length=1000) done: Optional[bool] = None class TodoOut(BaseModel): id: str title: str description: Optional[str] done: bool created_at: datetime updated_at: datetime class Page(BaseModel): items: List[TodoOut] total: int limit: int offset: int # ----------------------------- # Repository (in-memory) # ----------------------------- @dataclass class TodoRecord: id: str title: str description: Optional[str] done: bool created_at: datetime updated_at: datetime class TodoRepo: def __init__(self) -> None: self._data: Dict[str, TodoRecord] = {} def create(self, payload: TodoCreate) -> TodoRecord: now = datetime.now(timezone.utc) todo_id = str(uuid4()) rec = TodoRecord( id=todo_id, title=payload.title, description=payload.description, done=False, created_at=now, updated_at=now, ) self._data[todo_id] = rec return rec def get(self, todo_id: str) -> TodoRecord: rec = self._data.get(todo_id) if not rec: raise KeyError(todo_id) return rec def update(self, todo_id: str, payload: TodoUpdate) -> TodoRecord: rec = self.get(todo_id) changed = False if payload.title is not None: rec.title = payload.title changed = True if payload.description is not None: rec.description = payload.description changed = True if payload.done is not None: rec.done = payload.done changed = True if changed: rec.updated_at = datetime.now(timezone.utc) return rec def delete(self, todo_id: str) -> None: if todo_id not in self._data: raise KeyError(todo_id) del self._data[todo_id] def list( self, *, q: Optional[str], done: Optional[bool], limit: int, offset: int, ) -> tuple[List[TodoRecord], int]: # Filter first items = list(self._data.values()) if done is not None: items = [t for t in items if t.done == done] if q: q_lower = q.lower() items = [ t for t in items if q_lower in t.title.lower() or (t.description and q_lower in t.description.lower()) ] total = len(items) # Stable ordering: newest first items.sort(key=lambda t: t.created_at, reverse=True) # Pagination slice paged = items[offset: offset + limit] return paged, total # ----------------------------- # Dependency: repo singleton # ----------------------------- repo = TodoRepo() def get_repo() -> TodoRepo: return repo # ----------------------------- # Background task example # ----------------------------- def notify_created(todo_id: str) -> None: # In real apps, send email / push message / enqueue job. # This is intentionally simple. print(f"[notify] New todo created: {todo_id}") # ----------------------------- # FastAPI app # ----------------------------- app = FastAPI(title="Todo API") @app.get("/health") def health(settings: Settings = Depends(get_settings)) -> dict: return {"status": "ok", "app": settings.app_name} @app.post("/todos", response_model=TodoOut, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_api_key)]) def create_todo( payload: TodoCreate, background: BackgroundTasks, r: TodoRepo = Depends(get_repo), ) -> TodoOut: rec = r.create(payload) background.add_task(notify_created, rec.id) return TodoOut(**rec.__dict__) @app.get("/todos/{todo_id}", response_model=TodoOut, dependencies=[Depends(require_api_key)]) def get_todo(todo_id: str, r: TodoRepo = Depends(get_repo)) -> TodoOut: try: rec = r.get(todo_id) return TodoOut(**rec.__dict__) except KeyError: raise HTTPException(status_code=404, detail="Todo not found") @app.get("/todos", response_model=Page, dependencies=[Depends(require_api_key)]) def list_todos( q: Optional[str] = Query(default=None, description="Search in title/description"), done: Optional[bool] = Query(default=None, description="Filter by done=true/false"), limit: int = Query(default=10, ge=1, le=50), offset: int = Query(default=0, ge=0), r: TodoRepo = Depends(get_repo), ) -> Page: items, total = r.list(q=q, done=done, limit=limit, offset=offset) return Page( items=[TodoOut(**t.__dict__) for t in items], total=total, limit=limit, offset=offset, ) @app.patch("/todos/{todo_id}", response_model=TodoOut, dependencies=[Depends(require_api_key)]) def update_todo(todo_id: str, payload: TodoUpdate, r: TodoRepo = Depends(get_repo)) -> TodoOut: try: rec = r.update(todo_id, payload) return TodoOut(**rec.__dict__) except KeyError: raise HTTPException(status_code=404, detail="Todo not found") @app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_api_key)]) def delete_todo(todo_id: str, r: TodoRepo = Depends(get_repo)) -> None: try: r.delete(todo_id) return None except KeyError: raise HTTPException(status_code=404, detail="Todo not found")
3) Run it locally
Start the server:
uvicorn app.main:app --reload --port 8000
Open the interactive docs:
http://127.0.0.1:8000/docs(Swagger UI)http://127.0.0.1:8000/redoc(ReDoc)
4) Try it with curl (end-to-end)
Set an API key header. The default in code is dev-secret. (In real apps, store it in an env var like API_KEY.)
# Health check (no auth here) curl -s http://127.0.0.1:8000/health | python -m json.tool
Create a todo:
curl -s -X POST "http://127.0.0.1:8000/todos" \ -H "Content-Type: application/json" \ -H "X-API-Key: dev-secret" \ -d '{"title":"Ship v1","description":"Deploy the first version"}' | python -m json.tool
List todos with pagination:
curl -s "http://127.0.0.1:8000/todos?limit=10&offset=0" \ -H "X-API-Key: dev-secret" | python -m json.tool
Filter by done and search query:
curl -s "http://127.0.0.1:8000/todos?done=false&q=ship" \ -H "X-API-Key: dev-secret" | python -m json.tool
Update a todo (replace {id} with the returned id):
curl -s -X PATCH "http://127.0.0.1:8000/todos/{id}" \ -H "Content-Type: application/json" \ -H "X-API-Key: dev-secret" \ -d '{"done": true}' | python -m json.tool
5) Why these patterns matter (and scale well)
-
Request/response models: Using
TodoCreate,TodoUpdate, andTodoOutkeeps validation close to the API boundary. You avoid “stringly-typed” bugs and your docs stay accurate. -
Dependency injection:
Depends(get_repo)andDepends(get_settings)make it easy to swap implementations later (in-memory repo → database repo) without rewriting handlers. -
Consistent errors: The
HTTPExceptionresponses are predictable. Clients can reliably handle404vs401. -
Pagination built-in: Returning
{items, total, limit, offset}is a simple, common contract that’s easy for frontends to implement. -
BackgroundTasks: If creating a todo should trigger side effects (notifications, audit logs), you can offload that work without blocking the response. For heavier workloads, you’d usually move to a queue (Celery/RQ/Arq), but the API shape stays the same.
6) Next upgrades (still junior-friendly)
-
Add a database: Replace
TodoRepowith a DB-backed repo. Keep the same handler signatures and your API won’t change. -
Add routers: Split routes into
APIRoutermodules (e.g.,todos.py) as the app grows. -
Introduce versioning: Prefix endpoints with
/v1once you have external consumers. -
Better auth: Move from an API key to OAuth2/JWT when you need user accounts and permissions.
If you want, I can provide the same API refactored into a multi-file structure (routers/, schemas/, services/) or swap the repository to SQLite/Postgres while keeping the handlers nearly identical.
Leave a Reply