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:
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.
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.
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.
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:
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:
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:
# 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.
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
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:
- Build a
GET /tasks/searchendpoint accepting optional status, assignee, and date filters, composed at runtime withselect()— confirm it works correctly with zero, one, and all filters supplied. - Reproduce a real N+1: seed at least 30 tasks with assignees, iterate the list accessing
task.assigneeon each, and count the queries withecho=True. Then addselectinloadand confirm the query count drops to two. - 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. - 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.
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?
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?
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?
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.