Week 5: Relationships, Transactions & Alembic

Last week the Task model you built stood alone — one table, no connections to anything else. Real applications are rarely that flat: a task belongs to a project, a project has many tasks, and the shape of those connections directly determines how many queries your endpoints fire. This week adds a Project model related to Task by a real foreign key, shows exactly how careless relationship loading turns one request into dozens of queries (the "N+1" problem) and how selectinload() fixes it, and closes with Alembic — the tool that turns a model change into a reviewable, versioned schema migration instead of a manual ALTER TABLE you hope you typed correctly.

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

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

  • Model one-to-many and many-to-many relationships with relationship(), back_populates and foreign keys
  • Diagnose the N+1 query problem and fix it with selectinload() eager loading
  • Wrap multi-write operations in an atomic transaction, and generate, review and apply an Alembic migration

1. Relationships & Foreign Keys

A one-to-many relationship — one project, many tasks — is expressed with a foreign key column on the "many" side plus a relationship() on both model classes. The foreign key is what actually exists in the database; relationship() is a Python-only convenience that lets you navigate the connection as attributes (project.tasks, task.project) instead of writing the join yourself every time:

src/app/models/project.py & task.py
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base


class Project(Base):
    __tablename__ = "projects"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    tasks: Mapped[list["Task"]] = relationship(back_populates="project")


class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id"))
    project: Mapped["Project | None"] = relationship(back_populates="tasks")

back_populates links the two relationship() calls together as two ends of the same association: assign task.project = some_project and SQLAlchemy automatically appends task to some_project.tasks in memory too, keeping both sides consistent without a second query. Skip back_populates on one side and the two attributes become independent — the foreign key column in the database is still correct, but your in-memory object graph can silently drift out of sync with it.

Many-to-many relationships — tasks that can each carry several tags, and tags reused across many tasks — need a third table in between, an association table, since a plain foreign key column can only point to one row:

src/app/models/tag.py
from sqlalchemy import Column, ForeignKey, Table

from app.db.base import Base

task_tags = Table(
    "task_tags",
    Base.metadata,
    Column("task_id", ForeignKey("tasks.id"), primary_key=True),
    Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)


class Tag(Base):
    __tablename__ = "tags"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(unique=True)
    tasks: Mapped[list["Task"]] = relationship(secondary=task_tags, back_populates="tags")

secondary=task_tags tells SQLAlchemy to route the relationship through that join table automatically — appending to task.tags inserts a row into task_tags behind the scenes, and removing an entry deletes it, without you ever writing raw SQL against the association table yourself.

2. Loading Strategies & the N+1 Problem

By default, accessing a relationship() attribute that hasn't been loaded yet triggers lazy loading: SQLAlchemy quietly runs a new query the moment you touch project.tasks. That's convenient in isolation, but it becomes a real performance problem the moment you loop over multiple parent rows — this is the classic N+1 problem.

Concretely: select(Project) for 20 projects runs 1 query. Then, if your serializer or route handler reads project.tasks for each of those 20 projects, lazy loading fires one additional query per project — 20 more queries, each fetching one project's tasks separately. That's 21 total round trips to render one response, and the number keeps growing linearly with however many projects you return. Eager loading with selectinload() collapses that back down to a fixed, bounded number:

src/app/repositories/projects.py
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload

from app.models.project import Project


def list_projects_with_tasks(session: Session) -> list[Project]:
    stmt = select(Project).options(selectinload(Project.tasks))
    return list(session.scalars(stmt))

selectinload(Project.tasks) runs exactly 2 queries total regardless of how many projects come back: one SELECT for the projects, and one follow-up SELECT ... WHERE task.project_id IN (...) that pulls every related task for every returned project in a single round trip, using the already-known project ids. SQLAlchemy then matches each task back to its project in Python. Twenty projects went from 21 queries down to 2 — and the count stays at 2 even at two hundred projects, which is the actual fix, not just a smaller number.

selectinload vs joinedload

selectinload() issues a second, separate SELECT and works well for one-to-many collections. joinedload() instead adds a LEFT JOIN to the original query, returning everything in one round trip — better for a single to-one relationship, but it can multiply and duplicate parent rows in the result set when the related collection is large. Default to selectinload() for collections unless you've measured a reason to switch.

3. Atomic Transactions & Alembic Migrations

Any operation that writes to more than one row — or more than one table — should commit or fail as a single unit. session.begin() used as a context manager gives you exactly that: it commits automatically if the block finishes cleanly, and rolls back automatically if anything inside it raises, before the exception propagates:

src/app/repositories/projects.py
def bulk_add_tasks(session: Session, project: Project, titles: list[str]) -> list[Task]:
    created: list[Task] = []
    with session.begin():
        for title in titles:
            task = Task(title=title, project_id=project.id)
            session.add(task)
            created.append(task)
    # Exiting the "with" block cleanly triggers commit().
    # An exception raised inside it triggers rollback() instead --
    # either every task above is persisted, or none of them are.
    return created

Without that boundary, a failure on the third of five inserts would leave the first two committed and the database in a state your application never intended to produce. Wrapping the whole operation in one transaction means a partial failure leaves the database exactly as it was before the request started — there's no in-between state to reason about.

Now that your models actually have relationships, changing them by hand-editing the database is too error-prone to trust — that's what Alembic is for. It compares your SQLAlchemy models against the live database schema and generates a versioned, reviewable migration script:

terminal
# One-time setup
poetry add alembic
poetry run alembic init alembic

# In alembic/env.py, point target_metadata at your models:
#   from app.db.base import Base
#   target_metadata = Base.metadata

# Autogenerate a migration from the current model definitions
poetry run alembic revision --autogenerate -m "add projects and task ownership"

# ALWAYS read the generated script in alembic/versions/ before applying it

# Apply it to the database
poetry run alembic upgrade head

Autogenerate is a heuristic, not a guarantee — it detects new tables and new columns reliably, but it can misread a renamed column as an unrelated "drop old column, add new column" pair, which silently discards existing data on upgrade. Reading the generated script in alembic/versions/ before running upgrade head is a review step, not an optional formality.

4. Hands-on Exercise

Hands-on

Add project ownership, migrate the schema, and prove rollback works

Extend last week's persisted Tasks API with a Project model, generate a real migration for it, then demonstrate that a failing multi-write operation leaves no partial data behind.

Requirements:

  1. Add a Project model (id, name) and a nullable project_id foreign key plus relationship() on Task, wired together with back_populates on both sides.
  2. Decide, and document in a comment, whether tasks can exist without a project — that decision determines whether project_id is nullable.
  3. Run alembic init, point target_metadata at Base.metadata in env.py, then run alembic revision --autogenerate -m "add projects and task ownership".
  4. Open the generated script and confirm it only adds the new table and column — no unexpected drops — before running alembic upgrade head.
  5. Add a POST /projects/{project_id}/tasks/bulk endpoint that inserts several tasks inside one session.begin() block, and make it raise partway through if any title is blank.
  6. Send a request where one of several titles is blank, then query the database directly afterward and confirm zero rows were inserted — the transaction rolled back cleanly.
Hint

To force a clean, deliberate failure rather than a random bug, raise a plain ValueError inside the loop as soon as you see a blank title — before calling session.add() for it. Since that raise happens inside the with session.begin(): block, every task added on earlier loop iterations rolls back too, not just the one that failed.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What problem does selectinload() solve?

It loads related rows in one additional, bounded query instead of firing one separate query per parent row — the N+1 problem. Fetching 20 projects and then reading project.tasks on each would otherwise cost 21 queries with lazy loading; with selectinload(Project.tasks) it costs exactly 2, and that count stays flat no matter how many projects come back.

Q2

What does back_populates actually do, and what breaks if you forget it on one side of a relationship?

It tells SQLAlchemy that two separate relationship() declarations are two ends of the same association, so changing one side — like assigning task.project = p — automatically keeps the other side's collection (p.tasks) in sync in memory. Forget it on one side and the two attributes become independent: the foreign key column in the database is still correct, but your in-memory objects can silently disagree with it until you re-query.

Q3

Why is accessing a lazy-loaded relationship after its session has closed a common source of bugs?

Lazy loading fetches the related rows on first attribute access, using the connection tied to the session that originally loaded the parent. If that session has already been closed — for example, the request's get_session() dependency finished and closed it, and something later (a background task, a serializer) touches project.tasks — SQLAlchemy has no open connection to fetch with and raises a DetachedInstanceError. Eager loading the relationships you know you'll need, up front, avoids this entirely.

Q4

Why should you always read the migration script Alembic autogenerates before running alembic upgrade head?

Autogenerate compares your models to the live schema using heuristics, and heuristics get things wrong: a renamed column is typically detected as an unrelated drop-and-add pair, which silently discards the existing column's data on upgrade instead of renaming it. Reading the generated script in alembic/versions/ before applying it is how you catch a destructive operation while it's still just a reviewable text file, not something that already ran against production data.