Week 4: SQLAlchemy 2.0 Fundamentals

Weeks 2 and 3 got the Tasks API's shape right — routing, Pydantic validation, error responses — but every task has been living in a plain Python list that resets the moment the process restarts. This week replaces that list with a real PostgreSQL table using SQLAlchemy 2.0's declarative, fully-typed style: models built from Mapped[] columns, a Session wired in as a per-request FastAPI dependency, and queries written with the modern select() construct instead of the legacy Query API. By the end you'll have swapped every in-memory operation in the Tasks API for a query that actually survives a restart.

Module 4 of 22 Week 4 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define declarative SQLAlchemy 2.0 models with typed Mapped columns, primary keys and defaults
  • Wire a per-request Session into FastAPI as a dependency, and explain why sessions must never be long-lived globals
  • Write select(), insert and update queries, and reason correctly about commit and rollback boundaries

1. Declarative Models & Mapped Columns

SQLAlchemy 2.0 introduced a fully typed declarative style built around DeclarativeBase, Mapped[] and mapped_column(). You still get the same ORM underneath, but every column is now expressed as a regular Python type annotation that your editor and mypy understand — no more guessing whether a column returns str or str | None from a bare Column(String) declaration in the legacy 1.x style.

src/app/db/base.py
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    """Shared base class -- every mapped model inherits from this."""
    pass

Every model class inherits from that one Base, and each attribute is declared as Mapped[SomeType]. The Mapped[] wrapper is what tells SQLAlchemy's mapper "this attribute is backed by a database column" and gives type checkers the real Python type; mapped_column() is where you configure the column-level details — primary key, server default, nullability:

src/app/models/task.py
from datetime import datetime

from sqlalchemy import func
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base


class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    description: Mapped[str | None] = mapped_column(default=None)
    is_done: Mapped[bool] = mapped_column(default=False)
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())

Nullability is inferred from the type itself: title: Mapped[str] becomes a NOT NULL column because str doesn't include None, while description: Mapped[str | None] becomes nullable automatically — you rarely need to pass nullable=True explicitly. server_default=func.now() asks Postgres itself to fill in the timestamp at insert time, which is more reliable than computing it in Python, since it stays correct even if a row is inserted through a raw SQL script that bypasses your application entirely.

Why this matters

Because these are ordinary type annotations, the same Mapped[str | None] vocabulary you used for Pydantic response models in Week 2 now describes your database schema too. A model class becomes a single, honest source of truth for "what shape does a task actually have" — your editor autocompletes task.title correctly, and a typo like task.titel is a type error, not a silent None at runtime.

2. Session Lifecycle as a FastAPI Dependency

A Session is the ORM's unit of work: it tracks every object you load or add, translates your query calls into SQL over a connection it borrows from a pool, and stages changes until you explicitly commit(). Creating one starts with an engine — the object that actually knows how to talk to Postgres and manages the underlying connection pool — built once at startup from a connection URL:

src/app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.core.config import settings

engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)


def get_session():
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()

get_session() is a generator dependency: FastAPI calls it, receives the value produced by yield, injects that Session into your route, and — critically — resumes the generator after the response has been sent, running whatever comes after yield as guaranteed cleanup. That's what makes session.close() run for every request, success or failure, without you having to remember it in each route.

src/app/routers/tasks.py
from typing import Annotated

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.db.session import get_session

router = APIRouter(prefix="/tasks", tags=["tasks"])
SessionDep = Annotated[Session, Depends(get_session)]


@router.get("/{task_id}")
def get_task(task_id: int, session: SessionDep):
    ...

A single module-level Session shared across requests would be a serious bug, not a shortcut: Session objects are not thread-safe, so two concurrent requests touching the same instance can corrupt each other's identity map and issue queries on top of each other's uncommitted changes. Creating a fresh Session per request — and closing it when the request ends — is what keeps each request's work isolated and returns its connection to the pool promptly instead of holding it open indefinitely.

pool_pre_ping matters more than it looks

pool_pre_ping=True makes the engine test a pooled connection with a cheap round trip before handing it to your session. Without it, a connection that Postgres or a load balancer silently dropped while idle in the pool surfaces as a confusing "server closed the connection unexpectedly" error on a completely unrelated request.

3. Select, Insert, Update & Transactions

SQLAlchemy 2.0 standardizes on the select() construct for every query, whether you're using the ORM or writing Core SQL directly — the legacy session.query(Task) style still works but is considered legacy. You build a select() statement, then hand it to the session to execute:

src/app/repositories/tasks.py
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.task import Task


def find_task(session: Session, task_id: int) -> Task | None:
    return session.scalar(select(Task).where(Task.id == task_id))


def list_tasks(session: Session) -> list[Task]:
    return list(session.scalars(select(Task).order_by(Task.id)))


def create_task(session: Session, title: str, description: str | None) -> Task:
    task = Task(title=title, description=description)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


def update_task(session: Session, task: Task, *, is_done: bool) -> Task:
    task.is_done = is_done
    session.commit()
    return task

session.scalar(...) runs the statement and returns a single column value or None — ideal for "find one row by id." session.scalars(...) returns every matching row as ORM objects, which is what you iterate over for a list endpoint. Neither of these touches the database until you actually call it; building a select() object is just assembling SQL in memory.

session.add(task) doesn't write anything either — it just puts the new object under the session's tracking. The actual INSERT (or UPDATE, for changes to already-tracked objects like the is_done assignment above) happens on flush, which commit() triggers automatically. commit() is your transaction boundary: everything the session has staged since the last commit is written atomically, or none of it is. If anything raises before you reach commit(), call session.rollback() to discard the staged changes and leave the database exactly as it was — otherwise the session is left in a broken, half-flushed state that will raise confusing errors on the next query.

terminal
# Quick sanity check against a running Postgres instance
poetry run python -c "
from app.db.session import SessionLocal
from app.repositories.tasks import create_task, find_task

with SessionLocal() as session:
    t = create_task(session, 'Ship week 4', None)
    print(find_task(session, t.id))
"

4. Hands-on Exercise

Hands-on

Move the Tasks API from memory to PostgreSQL

Take the Week 2–3 Tasks API and give it a real, persistent backing store using everything from this week.

Requirements:

  1. Add SQLAlchemy 2.0 and a PostgreSQL driver (psycopg[binary] or asyncpg) to the project, and build the engine from a DATABASE_URL read via config/environment variables — never hardcoded in source.
  2. Define a Task model with id, title, description: str | None, is_done: bool (default False) and created_at with a server-side default.
  3. Implement get_session() as a generator dependency backed by a sessionmaker, and inject it into every route with Annotated[Session, Depends(get_session)].
  4. Replace every in-memory list operation in the existing list/get/create/update/delete routes with the matching select(), session.add(), or attribute-assignment-plus-commit() call.
  5. Raise HTTPException(status_code=404) whenever find_task returns None, and make sure a failed request never leaves a partial commit behind.
  6. Prove persistence: create a task, restart the Uvicorn process, and confirm GET /tasks/{id} still returns it.
Hint

No local Postgres yet? docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=devpass postgres:16 gets you a disposable instance in seconds. Point DATABASE_URL at postgresql+psycopg://postgres:devpass@localhost:5432/postgres and you're unblocked without installing anything system-wide.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why should one HTTP request typically use one short-lived Session?

It gives the request a clear, self-contained transaction boundary — everything the request writes either commits together or rolls back together — and it guarantees the connection the session borrowed is returned to the pool as soon as the request finishes. A session shared across requests has neither guarantee: it isn't thread-safe, so concurrent requests can corrupt its tracked state, and it can hold a pooled connection open indefinitely.

Q2

In id: Mapped[int] = mapped_column(primary_key=True), what does wrapping the type in Mapped[int] give you that a bare id: int annotation would not?

Mapped[int] is what tells SQLAlchemy's declarative mapper that this attribute is backed by an actual database column, not just a regular class attribute — a bare id: int would be invisible to the mapper entirely. mapped_column() is the separate piece that configures the column itself (here, marking it the primary key); together they give you a column that's both correctly mapped at runtime and accurately typed for your editor and mypy.

Q3

Why does get_session() use yield instead of return?

yield turns the function into a generator-based FastAPI dependency: FastAPI runs the code before yield, hands your route the yielded Session, and — once the route has finished and a response is on its way — resumes the generator to run everything after yield as teardown. That's how session.close() in the finally block runs on every request, success or exception, without every route having to remember to call it manually.

Q4

What's the practical difference between title: Mapped[str] and description: Mapped[str | None] on the same model?

SQLAlchemy 2.0 infers the column's nullability directly from whether the annotated type includes None. title: Mapped[str] becomes a NOT NULL column at the database level, while description: Mapped[str | None] becomes NULLABLE — both without passing nullable= explicitly to mapped_column(). Get the annotation wrong and the schema Alembic generates next week will be wrong too.