FastAPI in Practice: Build a Clean CRUD API with Validation, Dependencies, and SQLModel

FastAPI in Practice: Build a Clean CRUD API with Validation, Dependencies, and SQLModel

If you’ve built a few APIs, you’ve probably felt the pain of “it works, but it’s messy”: request validation scattered around, database code inside route handlers, and inconsistent error responses. FastAPI makes it easy to ship quickly, but you still need a little structure to keep things maintainable.

In this hands-on guide, you’ll build a small but production-shaped CRUD API for “projects” using:

  • FastAPI for routing + validation
  • SQLModel (Pydantic + SQLAlchemy) for models and DB access
  • Dependency injection for DB sessions
  • Consistent responses, pagination, and partial updates

The result is a simple pattern you can reuse across real projects.

1) Project setup

Create and activate a virtual environment, then install dependencies:

python -m venv .venv # macOS/Linux source .venv/bin/activate # Windows (PowerShell) # .venv\Scripts\Activate.ps1 pip install fastapi uvicorn sqlmodel

We’ll use SQLite for simplicity. The same approach works with Postgres/MySQL by swapping the database URL and driver.

2) Define models the “clean” way: Create/Read/Update schemas

A common beginner mistake is using one model for everything. Instead, define separate schemas:

  • Project: the DB table
  • ProjectCreate: request body for POST
  • ProjectRead: response model
  • ProjectUpdate: PATCH body (all optional)

Create app.py:

from datetime import datetime from typing import Optional, List from fastapi import FastAPI, Depends, HTTPException, Query, status from sqlmodel import SQLModel, Field, Session, create_engine, select DATABASE_URL = "sqlite:///./app.db" engine = create_engine(DATABASE_URL, echo=False) app = FastAPI(title="Projects API") # --- Models (DB + schemas) --- class ProjectBase(SQLModel): name: str = Field(min_length=2, max_length=100) description: Optional[str] = Field(default=None, max_length=500) class Project(ProjectBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=datetime.utcnow) class ProjectCreate(ProjectBase): pass class ProjectRead(ProjectBase): id: int created_at: datetime class ProjectUpdate(SQLModel): name: Optional[str] = Field(default=None, min_length=2, max_length=100) description: Optional[str] = Field(default=None, max_length=500)

Why this matters:

  • Clients can’t accidentally send id or created_at on create.
  • Your API responses stay stable even if your DB table changes.
  • PATCH becomes easy: optional fields map cleanly to “partial update”.

3) Database session dependency (the “FastAPI way”)

Instead of creating sessions in every route, define a dependency that yields a session and guarantees cleanup:

# --- DB helpers --- def create_db_and_tables() -> None: SQLModel.metadata.create_all(engine) def get_session(): with Session(engine) as session: yield session @app.on_event("startup") def on_startup(): create_db_and_tables()

Now any endpoint can request a Session via Depends(get_session). This keeps handlers focused on behavior, not plumbing.

4) CRUD endpoints (Create, Read, Update, Delete)

Let’s build endpoints with good defaults: clear status codes, helpful errors, and predictable output models.

Create

@app.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED) def create_project(payload: ProjectCreate, session: Session = Depends(get_session)): project = Project.model_validate(payload) session.add(project) session.commit() session.refresh(project) return project

Notes:

  • response_model=ProjectRead ensures you don’t leak internal fields.
  • session.refresh() reloads generated values like id.

List with pagination + search

Lists are where APIs get messy. Add simple pagination and optional search from day one:

@app.get("/projects", response_model=List[ProjectRead]) def list_projects( session: Session = Depends(get_session), q: Optional[str] = Query(default=None, description="Search in project name"), limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), ): stmt = select(Project).order_by(Project.id.desc()).offset(offset).limit(limit) if q: # SQLite case-insensitive match. For Postgres, consider ILIKE. stmt = stmt.where(Project.name.contains(q)) return session.exec(stmt).all()

This gives you URLs like:

  • GET /projects?limit=10&offset=0
  • GET /projects?q=demo

Get by ID

@app.get("/projects/{project_id}", response_model=ProjectRead) def get_project(project_id: int, session: Session = Depends(get_session)): project = session.get(Project, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") return project

Patch update (partial updates done right)

PATCH should only update the fields the client sends. Don’t overwrite fields with null unless that’s explicitly desired.

@app.patch("/projects/{project_id}", response_model=ProjectRead) def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session)): project = session.get(Project, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") updates = payload.model_dump(exclude_unset=True) for key, value in updates.items(): setattr(project, key, value) session.add(project) session.commit() session.refresh(project) return project

Key trick: exclude_unset=True prevents missing fields from being treated as updates.

Delete

@app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_project(project_id: int, session: Session = Depends(get_session)): project = session.get(Project, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") session.delete(project) session.commit() return None

204 means “success, no response body”. Clean and standard.

5) Run it locally

Start the dev server:

uvicorn app:app --reload

Open the interactive docs:

  • http://127.0.0.1:8000/docs (Swagger UI)
  • http://127.0.0.1:8000/redoc

Try a quick flow:

  • Create: POST /projects with {"name":"My App","description":"First project"}
  • List: GET /projects
  • Patch: PATCH /projects/1 with {"description":"Updated"}
  • Delete: DELETE /projects/1

6) Practical tips that save you later

  • Keep DB code out of your route handlers as your app grows.
    Today’s CRUD is small, but tomorrow you’ll want a services/ layer (business logic) and a repos/ layer (DB access). The dependency pattern you used here makes that refactor painless.

  • Always validate at the boundary.
    FastAPI + Pydantic schemas are your “front door.” Validate request bodies, query params, and path params there so your internal code can assume it’s getting good data.

  • Use consistent error shapes.
    In real teams, you’ll eventually standardize {"detail": "...", "code": "..."} or similar. Starting with HTTPException at least keeps status codes correct.

  • Prefer PATCH for partial updates.
    A lot of bugs come from “client didn’t send field X so we erased it.” The exclude_unset pattern prevents that class of bug.

  • Add pagination early.
    Even if you “only have a few rows,” list endpoints tend to become hot paths. Basic limit/offset is easy and immediately useful.

7) Where to go next

This article intentionally kept the core small and practical. The next upgrades that fit nicely into this structure are:

  • Alembic migrations (instead of create_all)
  • Auth (API keys or JWT) and per-user ownership checks
  • Background jobs for slow tasks (email, exports)
  • Switch SQLite to Postgres in production

If you implement the patterns above—separate schemas, session dependencies, PATCH with exclude_unset, and paginated list endpoints—you’ll have a FastAPI codebase that stays readable even as it grows past “toy app” size.


Leave a Reply

Your email address will not be published. Required fields are marked *