Cheat SheetsFastAPIDatabases

Databases — Cheat Sheet

FastAPI · 5 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Databases
FastAPI5 topicsQuick revision reference
1

SQLAlchemy Setup — Engine, Sessions & Models

One engine per app (with a connection pool), one session per request (via the get_db yield dependency), ORM models declaring tables in Python — the wiring every FastAPI + Postgres service shares.

  • Engine created once with the pool; sessionmaker stamps out per-request sessions
  • get_db yield dependency: commit on success, rollback on error, always close
  • SQLAlchemy 2.x: Mapped[]/mapped_column models, select() queries, db.get() for PKs
  • pool_pre_ping=True survives DB restarts; size pools against max_connections
Engine once, typed models, sessionmaker as the factory
# pip install sqlalchemy psycopg2-binary

# ── app/core/db.py ──────────────────────────
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

engine = create_engine(
    "postgresql://app:app@localhost:5432/placement",   # from Settings in real code
    pool_size=5,           # steady connections held open
    max_overflow=10,       # extra under burst (returned when idle)
    pool_pre_ping=True,    # test connection before use — survives DB restarts
    echo=False,            # True in dev = log every SQL statement
)
SessionLocal = sessionmaker(bind=engine, autoflush=False)

class Base(DeclarativeBase):
    pass

# ── app/models/student.py ───────────────────
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

class Student(Base):
    __tablename__ = "students"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(60))
    email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
    cgpa: Mapped[float] = mapped_column(default=0.0)
    branch: Mapped[str] = mapped_column(String(10), index=True)
    offers: Mapped[list["Offer"]] = relationship(back_populates="student")

class Offer(Base):
    __tablename__ = "offers"
    id: Mapped[int] = mapped_column(primary_key=True)
    student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True)
    company: Mapped[str] = mapped_column(String(80))
    ctc_lpa: Mapped[float]
    student: Mapped[Student] = relationship(back_populates="offers")

# Dev bootstrap only — real schema changes go through Alembic:
# Base.metadata.create_all(engine)
2

CRUD with SQLAlchemy — The Full Resource Pattern

The complete CRUD recipe: Pydantic In/Out schemas with from_attributes, add/commit/refresh on create, exclude_unset for PATCH, IntegrityError → 409, and eager loading to kill N+1 queries.

  • ConfigDict(from_attributes=True) lets response_model serialize ORM objects, nesting included
  • Create: add → commit → refresh; catch IntegrityError → 409
  • PATCH: load object, setattr only model_dump(exclude_unset=True) fields
  • N+1 on listings: selectinload (to-many) / joinedload (to-one) — count queries once with echo=True
add → commit → refresh; IntegrityError → 409, never 500
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, EmailStr
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

app = FastAPI()

class StudentIn(BaseModel):
    name: str
    email: EmailStr
    branch: str
    cgpa: float = 0.0

class OfferOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)   # read from ORM attributes
    company: str
    ctc_lpa: float

class StudentOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    branch: str
    cgpa: float
    offers: list[OfferOut] = []                       # nested ORM relationship

@app.post("/students", response_model=StudentOut, status_code=201)
def create_student(payload: StudentIn, db: Session = Depends(get_db)):
    student = Student(**payload.model_dump())
    db.add(student)
    try:
        db.commit()                    # INSERT happens here
    except IntegrityError:             # unique email hit
        db.rollback()
        raise HTTPException(409, f"email {payload.email} already registered")
    db.refresh(student)                # pull DB-generated id (and defaults)
    return student                     # ORM object → StudentOut, automatically

@app.get("/students/{student_id}", response_model=StudentOut)
def read_student(student_id: int, db: Session = Depends(get_db)):
    student = db.get(Student, student_id)
    if not student:
        raise HTTPException(404, "student not found")
    return student
3

Alembic Migrations — Evolving the Schema Safely

Alembic versions your schema like git versions code: autogenerate diffs models against the DB, you review the script, alembic upgrade head applies it — and create_all never touches production.

  • create_all never ALTERs — schema changes go through Alembic, always
  • Loop: model change → revision --autogenerate → REVIEW → upgrade head
  • Autogen misreads renames as drop+add and forgets NOT NULL server defaults
  • Run migrations as a release step; risky changes = expand → migrate → contract
model change → autogenerate → review → upgrade head
pip install alembic
alembic init alembic                     # creates alembic/ + alembic.ini

# ── alembic/env.py — the two lines that matter ──
from app.core.db import Base
from app.models import student, offer    # import ALL model modules (registers tables)
target_metadata = Base.metadata
# and set the URL from settings, not alembic.ini:
# config.set_main_option("sqlalchemy.url", get_settings().database_url)

# ── the daily loop ──
# 1. Edit the model: add  backlogs: Mapped[int] = mapped_column(default=0)
# 2. Generate:
alembic revision --autogenerate -m "add backlogs to students"
# 3. REVIEW alembic/versions/9f2c_add_backlogs_to_students.py:
def upgrade():
    op.add_column("students",
        sa.Column("backlogs", sa.Integer(), nullable=False,
                  server_default="0"))   # ← you often ADD this by hand:
                                         #    NOT NULL on a full table needs a default
def downgrade():
    op.drop_column("students", "backlogs")
# 4. Apply:
alembic upgrade head

# Everyday commands:
alembic current            # what version is this DB on?
alembic history            # the chain of migrations
alembic downgrade -1       # step back one (dev only, usually)
4

Async SQLAlchemy — asyncpg and AsyncSession

create_async_engine + AsyncSession + asyncpg make DB calls awaitable, so the event loop serves other requests during queries — but lazy loading breaks in async, making eager loading mandatory.

  • Sync DB calls inside async def block the event loop for every request
  • Stack: postgresql+asyncpg:// + async_sessionmaker + async get_db, await everything
  • Lazy loading raises MissingGreenlet in async — selectinload is mandatory
  • expire_on_commit=False; plain def + sync DB (thread pool) is a legitimate choice
asyncpg URL, async_sessionmaker, await get/execute/commit
# pip install sqlalchemy[asyncio] asyncpg

# ── app/core/db.py ──────────────────────────
from sqlalchemy.ext.asyncio import (
    AsyncSession, async_sessionmaker, create_async_engine,
)

engine = create_async_engine(
    "postgresql+asyncpg://app:app@localhost:5432/placement",  # ← asyncpg driver
    pool_size=5, max_overflow=10, pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)

async def get_db():
    async with AsyncSessionLocal() as db:     # async context manager closes it
        try:
            yield db
            await db.commit()
        except Exception:
            await db.rollback()
            raise

# ── endpoints: await every DB touch ─────────
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select

app = FastAPI()

@app.get("/students/{student_id}")
async def get_student(student_id: int, db: AsyncSession = Depends(get_db)):
    student = await db.get(Student, student_id)
    if not student:
        raise HTTPException(404, "student not found")
    return {"id": student.id, "name": student.name}

@app.get("/students")
async def search(branch: str, db: AsyncSession = Depends(get_db)):
    result = await db.execute(
        select(Student).where(Student.branch == branch).limit(20)
    )
    return [{"id": s.id, "name": s.name} for s in result.scalars()]
# While these awaits wait on Postgres, the SAME worker serves other requests.
5

Pagination, Filtering & Sorting — List Endpoints Done Right

Production list endpoints return an envelope (items + total + page), build filters dynamically but safely, whitelist sort fields against injection, and switch to keyset pagination when OFFSET gets slow.

  • Always cap size (le=100) and return an envelope: items + total + page info
  • Sort fields go through a whitelist dict — never client-supplied column names
  • Filters compose as a WHERE list; ilike with bound params is injection-safe
  • OFFSET degrades with depth and drifts; keyset (WHERE key < cursor) stays fast and stable
Envelope + dynamic WHERE + whitelisted ORDER BY
from fastapi import Depends, FastAPI, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session

app = FastAPI()

SORTABLE = {"cgpa": Student.cgpa, "name": Student.name, "id": Student.id}

class Page(BaseModel):
    items: list[StudentOut]
    total: int
    page: int
    size: int

@app.get("/students", response_model=Page)
def list_students(
    db: Session = Depends(get_db),
    branch: str | None = None,
    min_cgpa: float = Query(default=0, ge=0, le=10),
    q: str | None = Query(default=None, min_length=2),     # name search
    sort: str = Query(default="id"),
    order: str = Query(default="asc", pattern="^(asc|desc)$"),
    page: int = Query(default=1, ge=1),
    size: int = Query(default=20, ge=1, le=100),           # hard cap, always
):
    if sort not in SORTABLE:                # whitelist — NOT getattr(Student, sort)
        raise HTTPException(422, f"sort must be one of {sorted(SORTABLE)}")

    filters = [Student.cgpa >= min_cgpa]
    if branch:
        filters.append(Student.branch == branch)
    if q:
        filters.append(Student.name.ilike(f"%{q}%"))       # parameterised — safe

    col = SORTABLE[sort]
    stmt = (select(Student).where(*filters)
            .order_by(col.desc() if order == "desc" else col.asc())
            .offset((page - 1) * size).limit(size))

    total = db.execute(select(func.count()).select_from(Student).where(*filters)).scalar()
    items = db.execute(stmt).scalars().all()
    return Page(items=items, total=total, page=page, size=size)

# GET /students?branch=CS&min_cgpa=8&sort=cgpa&order=desc&page=2&size=25
# → {"items":[...25...], "total":312, "page":2, "size":25}
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/fastapi