Week 15: Advanced SQLAlchemy — Complex Queries, N+1 & Migrations at Scale

Weeks 4–5 got you to models, sessions, and the SQLAlchemy 2.0 query API. That's enough for straightforward CRUD; it stops being enough the moment a search endpoint needs several optional filters combined at runtime, or a list endpoint quietly fires a hundred extra queries because a relationship was touched inside a loop. This week covers the query patterns that actually show up in production repositories, and the migration discipline that keeps a schema change from taking your API down mid-deploy.

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

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

  • Build dynamic, runtime-composed queries with the 2.0 select() API
  • Eliminate N+1 queries with selectinload and joinedload
  • Run bulk operations and write an Alembic migration that doesn't lock the table

1. Dynamic Queries with the 2.0 select() API

A repository method like get_open_tasks(db) works when the filters are fixed. It stops working the moment a search endpoint needs to combine an arbitrary, optional subset of filters — status, assignee, and a date range, any of which might be omitted. SQLAlchemy 2.0's select() statement is a plain Python object you can build up incrementally, which makes composing it from optional conditions straightforward:

repositories/tasks.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

async def search_tasks(
    db: AsyncSession,
    status: str | None = None,
    assignee_id: int | None = None,
    created_after: datetime | None = None,
) -> list[Task]:
    stmt = select(Task)

    if status is not None:
        stmt = stmt.where(Task.status == status)
    if assignee_id is not None:
        stmt = stmt.where(Task.assignee_id == assignee_id)
    if created_after is not None:
        stmt = stmt.where(Task.created_at > created_after)

    result = await db.execute(stmt)
    return list(result.scalars().all())

Each .where() call returns a new, extended statement — nothing executes until db.execute(stmt) actually runs it — so building the query conditionally is just ordinary Python control flow, not a special query-builder DSL to learn. A caller with zero, one, or all three filters supplied gets exactly the right WHERE clause, with no combinatorial explosion of hand-written query methods to maintain.

Log the generated SQL while you build a dynamic query

Set echo=True on your engine (locally, never in production) while developing a search endpoint like this — seeing the exact SQL SQLAlchemy generates for each filter combination catches a subtle mistake (an AND where you meant OR, a filter silently applied twice) far faster than reasoning about it from the Python code alone.

2. Eager Loading & Solving N+1

By default, accessing a relationship on a lazily-loaded SQLAlchemy object fires a fresh query the moment you touch it — fine for one object, disastrous in a loop. Fetching 50 tasks and then accessing task.assignee on each one inside a list comprehension fires 1 query for the tasks, then 50 more, one per row, for their assignees. Eager loading tells SQLAlchemy up front which relationships to fetch alongside the main query, collapsing that into a small, fixed number of queries regardless of how many rows come back.

two eager-loading strategies
from sqlalchemy.orm import selectinload, joinedload

# selectinload: a SEPARATE query, but only ONE, using WHERE id IN (...)
stmt = select(Task).options(selectinload(Task.assignee))

# joinedload: a SINGLE query with a SQL JOIN
stmt = select(Task).options(joinedload(Task.assignee))

Both eliminate the N+1 pattern, but they trade off differently. joinedload fetches everything in one round trip via a LEFT JOIN, which is efficient for a many-to-one relationship (many tasks, one assignee each) but duplicates the parent row's columns once per matching child row for a one-to-many relationship, which can bloat the result set. selectinload issues a second query using WHERE assignee_id IN (...) against exactly the IDs the first query returned — still just two queries total regardless of how many tasks came back, without the row-duplication cost, which makes it the better default for one-to-many and many-to-many relationships.

Set lazy="raise" on relationships you expect to always eager-load explicitly

Configuring a relationship with lazy="raise" makes SQLAlchemy throw an exception the instant something touches it without an explicit eager-load option — instead of a silent extra query, you get an immediate, loud failure in tests pointing at exactly the line that forgot to eager-load. It turns "N+1 discovered in a slow-query log weeks later" into "N+1 caught by your test suite before merge."

3. Bulk Operations & Zero-Downtime Migrations

Calling db.add() in a loop for thousands of rows, then one commit(), still generates one INSERT statement per row under the hood. SQLAlchemy 2.0's ORM bulk operations batch that into far fewer round trips:

a bulk insert
from sqlalchemy import insert

await db.execute(
    insert(Task),
    [{"title": row.title, "status": "open"} for row in csv_rows],
)
await db.commit()

A bulk update() follows the same idea — one statement affecting many rows, executed directly against the database rather than loading each ORM object, mutating it, and letting the unit of work issue individual UPDATE statements:

a bulk update, bypassing the ORM identity map
from sqlalchemy import update

await db.execute(
    update(Task)
    .where(Task.updated_at < cutoff)
    .values(status="archived")
)
await db.commit()

Like a JPA @Modifying query, this bypasses the ORM's identity map entirely — any already-loaded Task objects in the current session won't reflect the change unless explicitly refreshed or expired.

Schema changes deserve the same care. Adding a NOT NULL column with a default on a large table can lock it for the duration of the rewrite in some databases — a multi-second lock is effectively an outage for every request touching that table. The safe pattern spreads the change across separate, sequential migrations instead of one:

expand-contract across three Alembic migrations
# Migration 1 (expand): add the column as NULLABLE -- no table lock
def upgrade():
    op.add_column("task", sa.Column("priority", sa.String(), nullable=True))

# Migration 2: application code starts writing it; backfill existing rows
def upgrade():
    op.execute("UPDATE task SET priority = 'normal' WHERE priority IS NULL")

# Migration 3 (contract): once backfilled and the app fully depends on it
def upgrade():
    op.alter_column("task", "priority", nullable=False)

No single migration in that sequence requires the old and new application code to disagree about the schema — which matters directly for a rolling deploy, where old and new instances briefly run side by side. A one-step migration that adds a NOT NULL column the app immediately depends on breaks the moment an old instance, still serving traffic mid-rollout, tries to insert a row without it.

Every migration should work with both the old and new app code running at once

This single rule is what generates the whole expand-contract pattern above: because a rolling deploy always has a window with two versions live, any migration that would break if only one version's assumptions held is unsafe by definition. Apply this instinct to every schema change, not just ones on large tables — it costs little on a small table and becomes a genuine habit for when it matters.

4. Hands-on Exercise

Hands-on

Build a dynamic search endpoint, kill a real N+1, and run a safe migration

Apply all three practices to the task service from earlier weeks.

Requirements:

  1. Build a GET /tasks/search endpoint accepting optional status, assignee, and date filters, composed at runtime with select() — confirm it works correctly with zero, one, and all filters supplied.
  2. Reproduce a real N+1: seed at least 30 tasks with assignees, iterate the list accessing task.assignee on each, and count the queries with echo=True. Then add selectinload and confirm the query count drops to two.
  3. Write a bulk insert seeding at least 1,000 rows, and compare its timing against a loop of individual db.add() calls doing the same thing.
  4. Write a three-migration expand-contract sequence adding a required column to an existing table, with your application still running (and working) throughout all three.
Hint

SQLAlchemy's Session.info dict or a simple query-counting event listener on before_cursor_execute gives you a precise, assertable query count in a test — a more reliable signal than eyeballing console log lines.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why does building a select() statement conditionally with plain Python if statements work correctly for optional filters?

A select() statement is an ordinary Python object, and .where() returns a new, extended statement rather than executing anything immediately. Nothing runs until db.execute() is actually called, so conditionally calling .where() inside an if block simply builds up whichever combination of conditions the supplied filters warrant, with no special query-builder syntax required.

Q2

Why is selectinload generally preferred over joinedload for a one-to-many relationship?

joinedload uses a SQL JOIN, which duplicates every column of the parent row once per matching child row on a one-to-many relationship, potentially bloating the result set significantly. selectinload issues a second, separate query filtered to exactly the parent IDs already fetched — still only two queries total, without that row-duplication cost, which makes it the more efficient default for one-to-many and many-to-many relationships.

Q3

Why does a rolling deploy require an expand-contract migration instead of adding a required column in one step?

A rolling deploy always has a window where old and new application instances run simultaneously against the same database. A single-step migration that immediately requires the new column breaks the old instances still serving traffic during that window, since they have no code path that populates it — splitting the change into expand, backfill, and contract phases keeps both versions working throughout the entire rollout.