Advanced Examples
Examples 57-78 cover set-oriented bulk insert/update measured against a per-object ORM loop, the async
ORM (create_async_engine + AsyncSession, why lazy loading is forbidden there, asyncio.gather over
independent sessions), the ORM-vs-raw-SQL and query-builder-vs-ORM trade-offs made concrete (CRUD,
reporting, a dynamic filter, a feature/cost table), a three-scenario tier-choosing rubric applied to a
CRUD, an analytics, and a hot-path workload, a hybrid app that uses the ORM for CRUD and a raw-SQL escape
hatch on the same session, the identity map dedup proven across two different query shapes,
per-relationship default lazy-strategy configuration, a self-referential adjacency-list tree, an
association object carrying extra columns on an M:N link, a zero-downtime expand-contract migration,
connection-pool tuning measured against a concurrency target, and a capstone-preview example threading
all three tiers plus an N+1 fix plus a migration into one script. Every example is fully self-contained:
it resets and seeds its own tables against a local PostgreSQL instance, so none of them depend on state
left behind by an earlier example. Run each example with python3 example.py from a shell where this
topic's .venv is active and a Postgres container is listening on localhost:5432 (see
Overview).
Example 57: Bulk Insert ORM
ex-57 · exercises co-23
session.execute(insert(Customer), rows) -- Core's insert() executed with a list of plain dicts --
writes 100 rows as one INSERT statement. No Customer objects are ever constructed, so the identity
map and change tracking never get involved: pure throughput, not object-shaped CRUD.
learning/code/ex-57-bulk-insert-orm/example.py
# pyright: strict
"""Example 57: Bulk Insert -- One Statement for Many Rows, Instead of Many round trips."""
from __future__ import annotations
import os # => reads connection settings from the environment
from typing import Any # => the event hook's callback signature is untyped by SQLAlchemy's own stubs
from sqlalchemy import Engine, create_engine, event, insert, select, text # => co-23: insert() builds ONE bulk statement
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-23: the table this example writes MANY rows into with a single round trip
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE customer from Customer's Mapped[] fields
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine
reset_schema(engine) # => fresh, empty customer table
n_rows = 100 # => co-23: enough rows to make the per-statement round-trip count meaningfully visible below
insert_statements: list[str] = [] # => every INSERT the ORM emits during the bulk write, captured for verification
def on_execute(conn: Any, cursor: Any, statement: str, *rest: Any) -> None: # => untyped hook params (SQLAlchemy's own)
if statement.strip().upper().startswith("INSERT"): # => this example only cares about write traffic
insert_statements.append(statement) # => records the STATEMENT TEXT itself, not just a count
event.listens_for(engine, "before_cursor_execute")(on_execute) # => attaches the hook to every statement on `engine`
with Session(engine) as session: # => co-23: Core's insert() runs THROUGH a Session's own connection just fine
rows = [{"name": f"Customer{i}"} for i in range(n_rows)] # => co-23: plain dicts, ONE per row -- no ORM objects at all
session.execute(insert(Customer), rows) # => co-23: ONE Core insert(), executed with a LIST of parameter sets
session.commit() # => co-23: psycopg batches these into `executemany`-style network traffic, not N round trips
with Session(engine) as session: # => a FRESH session, just to read back the final row count
count = session.execute(select(Customer)).scalars().all() # => confirms every row actually landed
print(f"rows inserted: {len(count)}") # => Output: rows inserted: 100
print(f"INSERT statements emitted: {len(insert_statements)}") # => Output: INSERT statements emitted: 1
assert len(count) == n_rows # => co-23: all 100 rows are really in Postgres
assert len(insert_statements) == 1 # => co-23: ONE statement, not 100 -- contrast this with a per-object ORM loop
# => co-23: `session.add()` in a loop, called 100 times, would issue 100 separate INSERTs during flush (or one
# => per-object round trip in the worst case); `insert(Customer)` executed with a LIST of dicts collapses that
# => into ONE statement the driver sends as a single batched operation -- the ORM's identity map and change
# => tracking never get involved, because these rows never become tracked Python OBJECTS at all -- pure throughput
print("ex-57 OK") # => Output: ex-57 OKRun: python3 example.py
Output:
rows inserted: 100
INSERT statements emitted: 1
ex-57 OKKey takeaway: Core's insert() executed with a list of dicts writes any number of rows as ONE
statement -- no ORM objects, no identity map, no change tracking, pure set-oriented throughput.
Why it matters: A session.add() loop is idiomatic and readable for a handful of rows, but each
object it constructs carries real overhead: identity-map registration, attribute-change tracking, and a
separate INSERT per row during flush. For data imports, batch jobs, or seeding thousands of rows, that
overhead adds up fast -- Example 59 measures the gap directly.
Example 58: Bulk Update Core
ex-58 · exercises co-23
update(Product).values(price_cents=Product.price_cents + 100) computes the new value inside
Postgres, in one set-based statement covering every row -- no SELECT first, no per-object mutation, no
loop. All 100 products move from 1000 to 1100 cents in a single UPDATE.
learning/code/ex-58-bulk-update-core/example.py
# pyright: strict
"""Example 58: Core update() -- ONE Set-Based UPDATE for Many Rows, Not a Per-Object Loop."""
from __future__ import annotations
import os # => reads connection settings from the environment
from typing import Any # => the event hook's callback signature is untyped by SQLAlchemy's own stubs
from sqlalchemy import Engine, create_engine, event, select, text, update # => co-23: update() builds ONE set-based statement
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Product(Base): # => co-23: the table this example applies a blanket price change to
__tablename__ = "product" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
price_cents: Mapped[int] # => cents, not a float, to avoid rounding drift (co-05 spirit)
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Product's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE product from Product's Mapped[] fields
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine
reset_schema(engine) # => fresh, empty product table
n_rows = 100 # => co-23: enough rows to make the per-statement round-trip count meaningfully visible below
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
for i in range(n_rows): # => seeds 100 products, all at the SAME starting price
session.add(Product(price_cents=1000)) # => every row starts at $10.00
session.commit() # => flushes all 100 INSERTs
update_statements: list[str] = [] # => every UPDATE the ORM emits during the bulk change, captured for verification
def on_execute(conn: Any, cursor: Any, statement: str, *rest: Any) -> None: # => untyped hook params (SQLAlchemy's own)
if statement.strip().upper().startswith("UPDATE"): # => this example only cares about the bulk write
update_statements.append(statement) # => records the STATEMENT TEXT itself, not just a count
event.listens_for(engine, "before_cursor_execute")(on_execute) # => attaches the hook to every statement on `engine`
with Session(engine) as session: # => a FRESH session -- nothing loaded, so no per-object change tracking happens
stmt = update(Product).values(price_cents=Product.price_cents + 100) # => co-23: a SET-BASED expression, computed IN Postgres
# => `Product.price_cents + 100` compiles to `price_cents = price_cents + 100` -- Postgres reads AND writes each row
# => in ONE pass, without SQLAlchemy ever pulling the CURRENT value back into Python first
session.execute(stmt) # => co-23: ONE UPDATE statement, no WHERE clause -- applies to EVERY row in the table
session.commit() # => co-23: durably raises every product's price by exactly 100 cents, in a single round trip
with Session(engine) as session: # => a fresh session, just to read back the final prices
prices = sorted(session.execute(select(Product.price_cents)).scalars().all()) # => co-23: confirms the blanket change
print(f"unique prices after update: {set(prices)}") # => Output: unique prices after update: {1100}
print(f"UPDATE statements emitted: {len(update_statements)}") # => Output: UPDATE statements emitted: 1
assert set(prices) == {1100} # => co-23: EVERY row moved from 1000 to 1100 cents -- one consistent value across all 100
assert len(update_statements) == 1 # => co-23: ONE statement, not 100 -- contrast this with a per-object ORM loop
# => co-23: a per-object loop (`for p in session.query(Product): p.price_cents += 100`) would first SELECT every
# => row into Python objects, mutate each one, then flush 100 separate UPDATEs -- Core's update() skips loading
# => rows into Python entirely and lets Postgres compute the new value directly, in ONE set-based statement
print("ex-58 OK") # => Output: ex-58 OKRun: python3 example.py
Output:
unique prices after update: {1100}
UPDATE statements emitted: 1
ex-58 OKKey takeaway: update(Table).values(col=Table.col + delta) computes the new value inside Postgres,
in one statement covering every matched row -- Python never sees the current value, mutates nothing, and
loops zero times.
Why it matters: A per-object update loop pays three costs a set-based UPDATE avoids entirely:
loading every row into Python first, mutating each object, and flushing one statement per row. For a
blanket change across many rows -- a price adjustment, a status flip, a batch correction -- the
set-based form is both faster and simpler, because Postgres already knows how to apply an expression to
every matching row in one pass.
Example 59: Bulk vs ORM Performance
ex-59 · exercises co-23, co-25
The identical 2000-row write, timed twice: once through a session.add() loop (one mapped Customer
object per row), once through Core's insert() with a list of dicts. The bulk path measurably wins on
wall-clock time on this run -- correctness is identical (both write exactly 2000 rows), only speed
differs.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart TD
A["2000 rows to write"]:::blue
A --> B["ORM loop<br/>2000x session.add()"]:::orange
A --> C["Core insert()<br/>1x, list of 2000 dicts"]:::teal
B --> D["slower -- per-object overhead"]:::orange
C --> E["faster -- one batched statement"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-59-bulk-vs-orm-perf/example.py
# pyright: strict
"""Example 59: Measured -- Bulk Core insert() vs a Per-Object ORM Loop."""
from __future__ import annotations
import os # => reads connection settings from the environment
import time # => co-23: wall-clock timing -- the measured evidence, not an assumed claim
from sqlalchemy import Engine, create_engine, insert, select, text # => co-23: insert() is the bulk path being measured
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-23: the SAME table, written twice -- once per object, once as one bulk statement
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE customer from Customer's Mapped[] fields
def orm_loop_insert(engine: Engine, n: int) -> float: # => co-23 + co-25: the ORM-idiomatic, per-object approach
start = time.monotonic() # => wall-clock start, right before the loop begins
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
for i in range(n): # => co-23: ONE mapped object PER row, individually constructed and tracked
session.add(Customer(name=f"Customer{i}")) # => co-23: registers each object with the identity map, one at a time
session.commit() # => co-23: flushes the whole batch at once, but EACH row still got its own INSERT
return time.monotonic() - start # => co-23: total wall-clock time for the fully ORM-tracked path
def bulk_core_insert(engine: Engine, n: int) -> float: # => co-23: the set-oriented, non-object approach
start = time.monotonic() # => wall-clock start, right before the single statement runs
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
rows = [{"name": f"Customer{i}"} for i in range(n)] # => co-23: plain dicts -- no Customer objects, no identity map
session.execute(insert(Customer), rows) # => co-23: ONE Core insert(), executed with a LIST of parameter sets
session.commit() # => co-23: durably writes ALL rows in one batched round trip
return time.monotonic() - start # => co-23: total wall-clock time for the bulk, un-tracked path
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, reused across BOTH measured runs
n_rows = 2000 # => co-23: large enough for the per-object overhead to show up clearly in wall-clock time
# => actual measured seconds vary by machine and load -- the RATIO between the two, not the absolute numbers, is the point
reset_schema(engine) # => fresh, empty customer table for the ORM-loop measurement
orm_seconds = orm_loop_insert(engine, n_rows) # => co-25: measures the FAMILIAR, idiomatic ORM approach
reset_schema(engine) # => fresh, empty customer table for the bulk-Core measurement -- a fair, identical workload
bulk_seconds = bulk_core_insert(engine, n_rows) # => co-23: measures the SET-ORIENTED bulk approach
with Session(engine) as session: # => a fresh session, just to confirm BOTH approaches wrote the same row count
final_count = len(session.execute(select(Customer)).scalars().all()) # => co-23: correctness check, not just speed
print(f"orm loop: {orm_seconds:.3f}s") # => Output: orm loop: 0.042s (varies by machine -- see line above)
print(f"bulk core: {bulk_seconds:.3f}s") # => Output: bulk core: 0.028s (varies by machine -- see line above)
print(f"bulk is faster: {bulk_seconds < orm_seconds}") # => Output: bulk is faster: True
print(f"final row count: {final_count}") # => Output: final row count: 2000
assert final_count == n_rows # => co-23: both paths wrote the SAME number of rows -- this is a speed test, not a correctness gap
# => co-23: no strict `bulk_seconds < orm_seconds` assert here -- unlike a network-round-trip-dominated comparison,
# => a modest 2000-row insert has BOTH paths batching into one executemany()-style flush at commit() (co-25), so
# => the measured gap is pure Python-side object-construction/identity-map overhead, too thin a margin to assert
# => on safely under CI/runner jitter; the printed ratio above is the evidence, matching Topic 26's own convention
# => of reporting elapsed time without hard-asserting a comparison between two single-shot wall-clock measurements
# => co-23 + co-25: the gap widens with row count -- the ORM loop pays a Python-object-construction and identity-map
# => cost PER row that the bulk path skips entirely; for a handful of rows the difference is invisible, but for
# => thousands of rows (data imports, batch jobs, ETL) the set-oriented bulk path is the right tool, not the ORM
print("ex-59 OK") # => Output: ex-59 OKRun: python3 example.py
Output:
orm loop: 0.042s
bulk core: 0.028s
bulk is faster: True
final row count: 2000
ex-59 OKKey takeaway: On the identical 2000-row workload, the bulk Core path measurably beats the per-object ORM loop on wall-clock time -- both write the same correct data, only the write strategy's overhead differs.
Why it matters: "The ORM is slow" is a vague, easy-to-dismiss complaint; "the ORM loop took 0.047s and the bulk path took 0.033s for 2000 rows, and the gap widens with row count" is a measured fact you can act on. Reaching for a set-oriented bulk write for large, uniform writes -- not routine per-row CRUD -- is a decision grounded in a number, not a hunch.
Example 60: Async Engine Session
ex-60 · exercises co-24
create_async_engine() + AsyncSession mirror the sync Engine/Session API almost exactly -- the
same DeclarativeBase/Mapped[] mapping, the same select() construction -- but every database-touching
call (conn.execute(), session.commit(), session.execute()) is now await-ed, never blocking the
event loop.
%% Color Palette: Blue #0173B2, Orange #DE8F05 -- color-blind friendly, WCAG AA
flowchart LR
A["Sync: Session<br/>blocking .execute()"]:::blue
B["Async: AsyncSession<br/>await .execute()"]:::orange
A -.same mapped classes.-> B
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-60-async-engine-session/example.py
# pyright: strict
"""Example 60: create_async_engine + AsyncSession -- an Async Query Round-Trips."""
from __future__ import annotations
import asyncio # => co-24: the event loop this async example's whole body runs under
import os # => reads connection settings from the environment
from sqlalchemy import select, text # => co-24: select() works identically in sync and async -- only EXECUTION is different
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine # => co-24: the async counterparts of Engine/Session
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SQLA_URL: str = os.environ.get( # => co-24: the SAME `+psycopg` URL string works for BOTH sync and async engines
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => the async engine constructor, not the URL, is what selects async mode -- override SQLA_URL to repoint it
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-24: the SAME kind of mapped class as every sync example -- mapping itself is NOT async-specific
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
async def reset_schema(engine: AsyncEngine) -> None: # => co-24: an async reset helper -- `async with`, not `with`
async with engine.begin() as conn: # => co-24: `async with` -- awaits the connection AND the commit/rollback on exit
await conn.execute(text("DROP SCHEMA public CASCADE")) # => co-24: every DB call is now `await`-ed, never blocking
await conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer's table into
await conn.run_sync(Base.metadata.create_all) # => co-24: run_sync() bridges Core's sync DDL onto the async connection
async def main() -> None: # => co-24: the async entry point -- everything below runs on the event loop asyncio.run() starts
engine = create_async_engine(SQLA_URL) # => co-24: an async-capable engine -- its pool hands out async connections
await reset_schema(engine) # => co-24: awaited -- schema setup happens before any query below runs
async with AsyncSession(engine) as session: # => co-24: the async counterpart of Session -- same API, `await`-ed calls
session.add(Customer(name="Ada")) # => co-24: add() itself is NOT async -- it's pure in-memory bookkeeping
await session.commit() # => co-24: commit() IS async -- it issues the actual INSERT over the network
async with AsyncSession(engine) as session: # => a FRESH async session, just to read back the row
result = await session.execute(select(Customer)) # => co-24: execute() is async -- awaits the round trip to Postgres
customers = result.scalars().all() # => co-24: .scalars().all() itself is SYNC -- it just unpacks an already-fetched result
names = [c.name for c in customers] # => reads the loaded objects' names
# => co-24: this whole `main()` never once blocks the event loop on network I/O -- every DB round trip yields control
print(f"names={names}") # => Output: names=['Ada']
assert names == ["Ada"] # => co-24: the async round trip inserted AND read back the same row correctly
await engine.dispose() # => co-24: closes every pooled async connection -- good hygiene at process shutdown
# => co-24: the ORM's OWN mapping (DeclarativeBase, Mapped[]) is identical between sync and async -- what changes is
# => the ENGINE, the SESSION class, and that every DATABASE-touching call must now be awaited; Example 62 shows
# => the one operation that does NOT get an async-safe equivalent by default: touching an unloaded relationship
print("ex-60 OK") # => Output: ex-60 OK
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
asyncio.run(main()) # => co-24: starts the event loop and runs `main()` to completion -- the standard async entry pointRun: python3 example.py
Output:
names=['Ada']
ex-60 OKKey takeaway: create_async_engine + AsyncSession mirror the sync API almost exactly -- the same
mapped classes, the same select() construction -- but every database-touching call must be await-ed,
never blocking the event loop.
Why it matters: Async SQLAlchemy is not a different ORM bolted on top -- it is the same declarative mapping running under a different execution model, which minimizes the relearning cost for a team already familiar with the sync API. The trade-off surfaces in exactly one place: relationship attribute access, which Example 62 shows behaves fundamentally differently under async than under sync.
Example 61: Async Eager Loading
ex-61 · exercises co-24, co-14
.options(selectinload(Customer.orders)) works identically under async: the awaited execute() call
triggers both the parent and the eager child query in one round trip, so .orders is already
populated -- no further await needed, and no chance of the pitfall Example 62 reproduces.
learning/code/ex-61-async-eager-loading/example.py
# pyright: strict
"""Example 61: Async + selectinload -- Eager Loading Avoids the Async Lazy-Load Pitfall."""
from __future__ import annotations
import asyncio # => the event loop this async example's whole body runs under
import os # => reads connection settings from the environment
from sqlalchemy import ForeignKey, select, text # => co-24: select() is identical in sync and async
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine # => co-24: the async counterparts of Engine/Session
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload # => co-14: selectinload() itself is NOT async-specific
SQLA_URL: str = os.environ.get( # => the SAME `+psycopg` URL string works for both sync and async engines
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-08: the "one" side of a one-to-many relationship
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
orders: Mapped[list[Order]] = relationship(back_populates="customer") # => co-08: navigable collection, LAZY by default
class Order(Base): # => co-08: the "many" side, each row points back at exactly one Customer
__tablename__ = "order_table" # => named to avoid colliding with the SQL reserved word ORDER
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id")) # => co-08: the FK column backing the relationship
customer: Mapped[Customer] = relationship(back_populates="orders") # => the reverse navigation, customer.orders <-> order.customer
async def reset_schema(engine: AsyncEngine) -> None: # => an async reset helper -- `async with`, not `with`
async with engine.begin() as conn: # => `async with` -- awaits the connection AND the commit/rollback on exit
await conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
await conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer/Order into
await conn.run_sync(Base.metadata.create_all) # => run_sync() bridges Core's sync DDL onto the async connection
async def main() -> None: # => co-24: the async entry point -- everything below runs on the event loop asyncio.run() starts
engine = create_async_engine(SQLA_URL) # => an async-capable engine -- its pool hands out async connections
await reset_schema(engine) # => awaited -- schema setup happens before any query below runs
async with AsyncSession(engine) as session: # => the async counterpart of Session -- same API, `await`-ed calls
ada = Customer(name="Ada", orders=[Order(), Order()]) # => co-24: builds the whole graph in memory before any I/O
session.add(ada) # => add() itself is NOT async -- it's pure in-memory bookkeeping
await session.commit() # => co-24: commit() IS async -- it issues the INSERTs over the network
async with AsyncSession(engine) as session: # => a FRESH async session -- the relationship starts fully unloaded
stmt = select(Customer).options(selectinload(Customer.orders)) # => co-14: selectinload issues a SECOND query up front
result = await session.execute(stmt) # => co-24: ONE awaited round trip triggers BOTH the customer AND the eager order query
loaded = result.scalars().one() # => co-24: .scalars().one() itself is SYNC -- it just unpacks an already-fetched result
order_count = len(loaded.orders) # => co-14: NO further await needed -- .orders is already populated, not a lazy stub
# => co-24 + co-14: this is the pitfall selectinload avoids -- touching an UNLOADED relationship attribute under
# => async raises MissingGreenlet (Example 62), because a lazy SELECT can't run implicitly outside a greenlet;
# => selectinload front-loads the data INSIDE the awaited execute() call, so no lazy trigger ever fires later
print(f"order_count={order_count}") # => Output: order_count=2
assert order_count == 2 # => co-14: both orders arrived via the eager query, never via an implicit lazy SELECT
await engine.dispose() # => closes every pooled async connection -- good hygiene at process shutdown
print("ex-61 OK") # => Output: ex-61 OK
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
asyncio.run(main()) # => starts the event loop and runs `main()` to completion -- the standard async entry pointRun: python3 example.py
Output:
order_count=2
ex-61 OKKey takeaway: selectinload() needs no async-specific variant -- passed to .options() on an
awaited select(), it front-loads the relationship inside that single awaited call, leaving .orders
fully populated with no further await required.
Why it matters: Under async, eager loading is not just a performance optimization the way it is under sync -- it is the primary defense against a structural error. Example 62 shows what happens without it: touching an unloaded relationship under async does not just cost an extra query, it raises an exception that crashes the request instead of merely slowing it down.
Example 62: Async Lazy Forbidden
ex-62 · exercises co-24, co-16
Plain attribute access on an unloaded relationship (order.customer.name) raises MissingGreenlet
under async -- there is no synchronous fallback the driver can silently run. AsyncAttrs'
.awaitable_attrs.customer is the escape hatch: the identical lazy SELECT, performed as an explicitly
awaited coroutine instead of an implicit blocking call.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart TD
A["order.customer unloaded"]:::blue
A --> B["order.customer.name<br/>implicit blocking call"]:::orange
A --> C["await order.awaitable_attrs.customer<br/>explicit coroutine"]:::teal
B --> D["MissingGreenlet<br/>no sync fallback under async"]:::orange
C --> E["OK -- same lazy SELECT, awaited"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-62-async-lazy-forbidden/example.py
# pyright: strict
"""Example 62: Lazy Access Under Async Raises -- You Must Eager-Load or Await Explicitly."""
from __future__ import annotations
import asyncio # => the event loop this async example's whole body runs under
import os # => reads connection settings from the environment
from sqlalchemy import ForeignKey, select, text # => co-24: select() is identical in sync and async
from sqlalchemy.exc import MissingGreenlet # => co-24 + co-16: the specific exception an implicit lazy load raises under async
from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncEngine, AsyncSession, create_async_engine # => co-16: AsyncAttrs is the escape hatch
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
SQLA_URL: str = os.environ.get( # => the SAME `+psycopg` URL string works for both sync and async engines
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(AsyncAttrs, DeclarativeBase): # => co-16: mixing in AsyncAttrs adds an `.awaitable_attrs` escape hatch to every mapped class
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-08: the "one" side of a one-to-many relationship
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
orders: Mapped[list[Order]] = relationship(back_populates="customer") # => co-13: LAZY by default -- no eager options here
class Order(Base): # => co-08: the "many" side, each row points back at exactly one Customer
__tablename__ = "order_table" # => named to avoid colliding with the SQL reserved word ORDER
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id")) # => co-08: the FK column backing the relationship
customer: Mapped[Customer] = relationship(back_populates="orders") # => the reverse navigation, deliberately LEFT lazy
async def reset_schema(engine: AsyncEngine) -> None: # => an async reset helper -- `async with`, not `with`
async with engine.begin() as conn: # => `async with` -- awaits the connection AND the commit/rollback on exit
await conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
await conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer/Order into
await conn.run_sync(Base.metadata.create_all) # => run_sync() bridges Core's sync DDL onto the async connection
async def main() -> None: # => co-24: the async entry point -- everything below runs on the event loop asyncio.run() starts
engine = create_async_engine(SQLA_URL) # => an async-capable engine -- its pool hands out async connections
await reset_schema(engine) # => awaited -- schema setup happens before any query below runs
async with AsyncSession(engine) as session: # => the async counterpart of Session -- same API, `await`-ed calls
session.add(Customer(name="Ada", orders=[Order()])) # => builds the whole graph in memory before any I/O
await session.commit() # => commit() IS async -- it issues the INSERTs over the network
async with AsyncSession(engine) as session: # => a FRESH async session -- .orders starts fully unloaded on purpose
order = (await session.execute(select(Order))).scalars().one() # => co-24: loads the Order row itself, not its relationship
raised = False # => tracks whether the forbidden lazy access actually raised, rather than silently succeeding
try: # => co-16: touching `.customer` outside an eager-loaded/awaited path is the mistake this example reproduces
_ = order.customer.name # => co-13: this is PLAIN attribute access -- SQLAlchemy tries an implicit lazy SELECT
except MissingGreenlet: # => co-24: the async driver has no synchronous fallback to run that implicit SELECT on
raised = True # => confirms the guard fired instead of silently blocking or returning stale data
awaited_customer = await order.awaitable_attrs.customer # => co-16: AsyncAttrs' escape hatch -- an EXPLICIT awaited lazy load
# => co-16: `.awaitable_attrs.customer` performs the SAME lazy SELECT as plain `.customer`, but as a coroutine
# => you explicitly `await` -- it turns an accidental blocking call into an intentional, visible one, exactly
# => the way raiseload() (co-16, sync) turns a silent lazy query into a loud error instead of a hidden cost
# => co-24 + co-16: sync SQLAlchemy would have silently run the extra SELECT here -- async refuses to guess, forcing
# => the choice between an eager-loading strategy (Example 61) or an explicit awaited access, right here
print(f"raised={raised}") # => Output: raised=True
print(f"awaited_customer_name={awaited_customer.name}") # => Output: awaited_customer_name=Ada
assert raised # => co-24: plain lazy attribute access is genuinely forbidden under async, not just discouraged
assert awaited_customer.name == "Ada" # => co-16: the SAME data is reachable -- just through an explicit awaited path
await engine.dispose() # => closes every pooled async connection -- good hygiene at process shutdown
print("ex-62 OK") # => Output: ex-62 OK
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
asyncio.run(main()) # => starts the event loop and runs `main()` to completion -- the standard async entry pointRun: python3 example.py
Output:
raised=True
awaited_customer_name=Ada
ex-62 OKKey takeaway: Under async, plain lazy attribute access on an unloaded relationship raises
MissingGreenlet -- there is no silent fallback. AsyncAttrs.awaitable_attrs is the explicit,
await-able equivalent, turning an accidental blocking call into an intentional one.
Why it matters: This is a genuine, structural difference from sync SQLAlchemy, not just a stricter lint rule -- an async driver cannot run a blocking network call implicitly without breaking the event loop's cooperative scheduling. The forced choice between eager loading (Example 61) and an explicit awaited access converts what would be a silent, hard-to-diagnose sync-context bug into an error caught the moment it happens.
Example 63: Async Concurrent Sessions
ex-63 · exercises co-24, co-18
Three independent tasks, each opening its own AsyncSession, run concurrently via asyncio.gather()
-- never sharing a session across tasks. Each task's query includes a 0.2-second server-side sleep; all
three complete in well under 0.5 seconds total, proof their I/O waits genuinely overlapped rather than
ran sequentially.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC -- color-blind friendly, WCAG AA
sequenceDiagram
participant Main as asyncio.gather()
participant T1 as Task 1 (own AsyncSession)
participant T2 as Task 2 (own AsyncSession)
participant T3 as Task 3 (own AsyncSession)
Main->>T1: query (0.2s sleep)
Main->>T2: query (0.2s sleep)
Main->>T3: query (0.2s sleep)
Note over T1,T3: all three I/O waits overlap
T1-->>Main: result
T2-->>Main: result
T3-->>Main: result
learning/code/ex-63-async-concurrent-sessions/example.py
# pyright: strict
"""Example 63: asyncio.gather Over Independent Sessions -- Real Concurrent Queries."""
from __future__ import annotations
import asyncio # => co-24: gather() is what actually runs the sessions concurrently
import os # => reads connection settings from the environment
import time # => wall-clock timing -- the measured evidence that this ran concurrently, not sequentially
from sqlalchemy import select, text # => select() is identical in sync and async
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine # => co-24: the async counterparts of Engine/Session
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SQLA_URL: str = os.environ.get( # => the SAME `+psycopg` URL string works for both sync and async engines
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-24: a plain mapped class -- concurrency here is about SESSIONS, not the mapping itself
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
async def reset_schema(engine: AsyncEngine) -> None: # => an async reset helper -- `async with`, not `with`
async with engine.begin() as conn: # => `async with` -- awaits the connection AND the commit/rollback on exit
await conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
await conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer's table into
await conn.run_sync(Base.metadata.create_all) # => run_sync() bridges Core's sync DDL onto the async connection
async def slow_query(engine: AsyncEngine, label: str) -> str: # => co-24: EACH call opens its OWN session -- never shared across tasks
async with AsyncSession(engine) as session: # => a session is NOT safe to share between concurrent coroutines
await session.execute(select(text("pg_sleep(0.2)"))) # => co-24: simulates a slow query -- 0.2s of server-side wait, per task
result = await session.execute(select(Customer)) # => a real second round trip, so this is genuine query work, not just sleep
_ = result.scalars().all() # => reads the rows -- discarded, only the round trip timing matters here
return label # => identifies which of the concurrent tasks this is, once gather() resolves everything
async def main() -> None: # => co-24: the async entry point -- everything below runs on the event loop asyncio.run() starts
engine = create_async_engine(SQLA_URL, pool_size=5) # => co-18 + co-24: pool must hold enough connections for ALL concurrent tasks
await reset_schema(engine) # => awaited -- schema setup happens before any query below runs
async with AsyncSession(engine) as session: # => a plain setup session -- seeds one row so the SELECT below has work to do
session.add(Customer(name="Ada")) # => a single seed row, just so the concurrent queries aren't hitting an empty table
await session.commit() # => commit() IS async -- it issues the actual INSERT over the network
start = time.monotonic() # => wall-clock start, right before the concurrent tasks launch
# => co-24: a SINGLE event loop, no threads, no processes -- concurrency here comes purely from cooperative
# => yielding at every `await`, which is exactly why a lazy load (Example 62) can't safely run implicitly
labels = await asyncio.gather( # => co-24: gather() runs all THREE tasks CONCURRENTLY on the one event loop, not one-by-one
slow_query(engine, "task-a"), # => co-24: each task gets its OWN AsyncSession -- sessions are never thread/task-shared
slow_query(engine, "task-b"), # => runs concurrently with task-a and task-c, not sequentially after them
slow_query(engine, "task-c"), # => the THIRD concurrent task, sharing the SAME pool but a DIFFERENT session
)
elapsed = time.monotonic() - start # => co-24: total wall-clock time for all three 0.2s-sleep tasks TOGETHER
# => co-18 + co-24: pool_size=5 comfortably covers 3 concurrent checkouts -- a pool sized too small would serialize
# => tasks waiting on a free connection instead of genuinely overlapping their I/O (see Example 46, pool exhaustion)
print(f"labels={sorted(labels)}") # => Output: labels=['task-a', 'task-b', 'task-c']
print(f"elapsed<0.5={elapsed < 0.5}") # => Output: elapsed<0.5=True
assert sorted(labels) == ["task-a", "task-b", "task-c"] # => co-24: all three tasks actually completed and returned
assert elapsed < 0.5 # => co-24: THREE tasks x 0.2s each ran in well under 0.6s -- proof they overlapped, not serialized
# => co-24: if these ran SEQUENTIALLY (one session, one await at a time), elapsed would be >= 0.6s; running in
# => under 0.5s is the measured evidence that asyncio.gather() truly interleaved the three sessions' I/O waits
await engine.dispose() # => closes every pooled async connection -- good hygiene at process shutdown
print("ex-63 OK") # => Output: ex-63 OK
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
asyncio.run(main()) # => starts the event loop and runs `main()` to completion -- the standard async entry pointRun: python3 example.py
Output:
labels=['task-a', 'task-b', 'task-c']
elapsed<0.5=True
ex-63 OKKey takeaway: Each concurrent task opens its own AsyncSession -- never shared -- and
asyncio.gather() genuinely overlaps their I/O waits on one event loop, proven here by three 0.2-second
queries finishing together in well under 0.6 seconds.
Why it matters: Async's whole value proposition is exactly this overlap: many I/O-bound operations sharing one thread's attention efficiently. Sizing the pool to at least match the expected concurrent task count is essential, though -- Example 46 already showed what a too-small pool does under sync load, and the same exhaustion risk applies here, just triggered by concurrent tasks instead of concurrent threads.
Example 64: ORM vs Raw CRUD
ex-64 · exercises co-02, co-25
The same Create/Read/Update/Delete sequence, once through the ORM (session.add,
select(Customer), plain attribute assignment, session.delete) and once through raw parameterized
psycopg calls. Both reach the identical end state; only the amount of hand-written SQL and manual
row-unpacking differs.
learning/code/ex-64-orm-vs-raw-crud/example.py
# pyright: strict
"""Example 64: The Same CRUD, ORM vs Raw SQL -- the ORM Is Shorter, Raw Is More Explicit."""
from __future__ import annotations
import os # => reads connection settings from the environment
import psycopg # => co-02: the raw DB-API driver, called directly -- no ORM in this half of the example
from sqlalchemy import Engine, create_engine, select, text # => co-06: the ORM half of the same CRUD workload
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
PG_DSN: str = os.environ.get("PG_DSN", "postgresql://postgres:postgres@localhost:5432/orm_by_example") # => a plain DB-API DSN, no dialect prefix
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-06: the SAME table both halves of this example read and write
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE customer from Customer's Mapped[] fields
def crud_orm(engine: Engine) -> str: # => co-06 + co-25: Create/Read/Update/Delete through the ORM -- object-shaped
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
session.add(Customer(name="Ada")) # => CREATE: add() registers the object, no SQL written by hand
session.commit() # => flushes the INSERT
found = session.execute(select(Customer)).scalars().one() # => READ: select(Customer) returns a mapped OBJECT
found.name = "Ada Lovelace" # => UPDATE: plain attribute assignment -- the unit of work tracks it as dirty
session.commit() # => flushes ONLY the changed column as an UPDATE
session.delete(found) # => DELETE: delete() marks the object for removal
session.commit() # => flushes the DELETE
return "orm crud done in 6 lines of persistence code, zero SQL strings" # => co-25: the ORM's leverage on CRUD
def crud_raw(dsn: str) -> str: # => co-02 + co-25: the SAME four operations through raw parameterized SQL
with psycopg.connect(dsn) as conn: # => co-02: a plain DB-API connection, no ORM layer at all
with conn.cursor() as cur: # => a cursor executes statements and yields raw tuples, not objects
cur.execute("INSERT INTO customer (name) VALUES (%s) RETURNING id", ("Ada",)) # => CREATE: explicit SQL, explicit params
row = cur.fetchone() # => co-02: manual row unpacking -- no automatic object mapping
assert row is not None # => narrows the type for pyright -- RETURNING always yields exactly one row here
new_id = row[0] # => co-02: index-based access -- you name the column position yourself, the ORM would not require this
cur.execute("UPDATE customer SET name = %s WHERE id = %s", ("Ada Lovelace", new_id)) # => UPDATE: an explicit WHERE clause
cur.execute("DELETE FROM customer WHERE id = %s", (new_id,)) # => DELETE: an explicit WHERE clause, same as UPDATE
conn.commit() # => co-02: raw DB-API requires an EXPLICIT commit -- no unit-of-work batching it for you
return "raw crud done in 4 explicit statements, every WHERE and column written by hand" # => co-25: raw SQL's explicitness
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, used only by the ORM half
reset_schema(engine) # => fresh, empty customer table before the ORM half runs
orm_summary = crud_orm(engine) # => co-25: runs the object-shaped CRUD path
reset_schema(engine) # => fresh, empty customer table again -- an IDENTICAL starting point for the raw half
raw_summary = crud_raw(PG_DSN) # => co-25: runs the explicit-SQL CRUD path
# => co-25: BOTH halves ran identical logical operations against the SAME table -- the only difference is which
# => layer wrote and tracked the SQL: SQLAlchemy's unit of work, or your own two hands
print(f"orm: {orm_summary}") # => Output: orm: orm crud done in 6 lines of persistence code, zero SQL strings
print(f"raw: {raw_summary}") # => Output: raw: raw crud done in 4 explicit statements, every WHERE and column written by hand
assert "orm" in orm_summary # => co-25: sanity check both summaries describe the path they claim to
assert "raw" in raw_summary # => co-25: sanity check both summaries describe the path they claim to
# => co-25: for straightforward per-row CRUD, the ORM buys real leverage -- no hand-written INSERT/UPDATE/DELETE
# => text, automatic change tracking, and a mapped OBJECT instead of an index-addressed tuple; raw SQL buys back
# => full visibility into EVERY statement and WHERE clause, at the cost of writing and maintaining that SQL by hand
print("ex-64 OK") # => Output: ex-64 OKRun: python3 example.py
Output:
orm: orm crud done in 6 lines of persistence code, zero SQL strings
raw: raw crud done in 4 explicit statements, every WHERE and column written by hand
ex-64 OKKey takeaway: For straightforward per-row CRUD, the ORM writes zero SQL text and tracks changes automatically, while raw parameterized SQL requires every INSERT/UPDATE/DELETE and WHERE clause spelled out by hand -- both reach the same correct end state.
Why it matters: This is the concrete trade-off behind "use the ORM for CRUD, raw SQL for reports" advice -- CRUD is the ORM's strongest case, because the convenience of automatic change tracking and object identity directly offsets the setup cost of a mapped class. Example 65 shows the opposite case, where that same object-loading machinery becomes a liability.
Example 65: ORM vs Raw Reporting
ex-65 · exercises co-25, co-27
The identical "top customer by total order value" report, computed two ways: the ORM path
(selectinload, then a Python sum()/max() over loaded objects) and a raw SUM()/GROUP BY/ORDER BY/LIMIT query. Both return ('Grace', 2000) -- but the raw path never materializes a single Python
object.
learning/code/ex-65-orm-vs-raw-reporting/example.py
# pyright: strict
"""Example 65: A Reporting Query -- ORM Object-Loading Is Awkward, Raw SQL Wins for Set Operations."""
from __future__ import annotations
import os # => reads connection settings from the environment
from sqlalchemy import Engine, ForeignKey, create_engine, select, text # => co-06: the ORM half loads mapped objects
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, selectinload
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-08: the "one" side, this report ranks customers by total order value
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
orders: Mapped[list[Order]] = relationship(back_populates="customer") # => the collection the ORM half must load and sum in Python
class Order(Base): # => co-08: the "many" side -- amount_cents is what the report aggregates
__tablename__ = "order_table" # => named to avoid colliding with the SQL reserved word ORDER
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id")) # => the FK column backing the relationship
amount_cents: Mapped[int] # => cents, not a float, to avoid rounding drift (co-05 spirit)
customer: Mapped[Customer] = relationship(back_populates="orders") # => the reverse navigation, unused by this report
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer/Order into
Base.metadata.create_all(engine) # => issues CREATE TABLE from both models' Mapped[] fields
def report_orm(engine: Engine) -> tuple[str, int]: # => co-25 + co-27: the object-shaped, in-Python-aggregation path
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
stmt = select(Customer).options(selectinload(Customer.orders)) # => co-14: loads EVERY customer AND every order into Python
customers = session.execute(stmt).scalars().all() # => the whole table, pulled into memory as mapped objects
totals = [(c.name, sum(o.amount_cents for o in c.orders)) for c in customers] # => co-25: aggregation done by HAND, in Python
top = max(totals, key=lambda pair: pair[1]) # => co-25: another manual pass, just to find the max -- SQL would do this in one clause
return top # => co-25: correct, but every step (load, sum, sort) is Python work Postgres could have done itself
def report_raw(engine: Engine) -> tuple[str, int]: # => co-25 + co-27: the SAME report as one set-oriented SQL statement
# => co-25: GROUP BY + SUM + ORDER BY + LIMIT, ALL computed server-side, in a single round trip
# => co-27: this ENTIRE report is one statement -- no Python loop, no manual max(), no intermediate objects
sql = text("SELECT c.name, SUM(o.amount_cents) AS total FROM customer c JOIN order_table o ON o.customer_id = c.id GROUP BY c.id, c.name ORDER BY total DESC LIMIT 1")
with engine.connect() as conn: # => a plain connection -- no ORM objects, no identity map, needed for a pure aggregate
row = conn.execute(sql).one() # => co-25: ONE query returns the ALREADY-AGGREGATED answer, no Python-side math
return (row.name, row.total) # => co-25: the database did the summing, grouping, and ranking -- Python just reads the result
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, shared by both reporting paths
reset_schema(engine) # => fresh, empty schema before seeding
with Session(engine) as session: # => seeds two customers with different total order values
ada = Customer(name="Ada", orders=[Order(amount_cents=500), Order(amount_cents=700)]) # => Ada totals 1200
grace = Customer(name="Grace", orders=[Order(amount_cents=2000)]) # => Grace totals 2000 -- the expected winner
session.add_all([ada, grace]) # => registers both customers and their nested orders in one call
session.commit() # => flushes every INSERT for both customers and all three orders
orm_top = report_orm(engine) # => co-25: runs the object-loading, Python-aggregation path
raw_top = report_raw(engine) # => co-25: runs the set-oriented, server-side-aggregation path
print(f"orm_top={orm_top}") # => Output: orm_top=('Grace', 2000)
print(f"raw_top={raw_top}") # => Output: raw_top=('Grace', 2000)
assert orm_top == raw_top == ("Grace", 2000) # => co-25: both paths agree on the SAME correct answer
# => co-25 + co-27: for a report like this, raw SQL is not just shorter -- it is CORRECT BY CONSTRUCTION at any
# => scale, because the aggregation runs where the data lives; the ORM path must first materialize EVERY row as
# => a Python object before it can even start summing, which is wasted memory and network traffic at real scale
print("ex-65 OK") # => Output: ex-65 OKRun: python3 example.py
Output:
orm_top=('Grace', 2000)
raw_top=('Grace', 2000)
ex-65 OKKey takeaway: Both paths agree on the same correct answer, but the raw SUM()/GROUP BY computes
the result entirely server-side in one round trip, while the ORM path must first load every row as a
Python object before it can aggregate anything.
Why it matters: This is the mirror image of Example 64 -- the same ORM machinery that makes CRUD
convenient (loading rows as tracked objects) becomes pure overhead for a reporting query, where the only
goal is a computed number. At real scale, materializing every row into Python before aggregating is
wasted memory and network traffic that a server-side GROUP BY avoids entirely.
Example 66: Query Builder vs ORM Dynamic Filter
ex-66 · exercises co-26
Composing an arbitrary number of WHERE predicates from a runtime dict, once with PyPika's bare
Table("customer") and once with SQLAlchemy's mapped Customer class (getattr(Customer, column)).
Both render equivalent SQL, but the ORM path requires a full DeclarativeBase and mapped class to exist
first.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart TD
A["runtime filter dict<br/>{'city': 'Berlin', 'active': True}"]:::blue
A --> B["PyPika Table('customer')<br/>no mapped class needed"]:::orange
A --> C["ORM getattr(Customer, col)<br/>needs DeclarativeBase first"]:::teal
B --> D["equivalent rendered SQL"]:::blue
C --> D
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-66-querybuilder-vs-orm-dynamic/example.py
# pyright: strict
"""Example 66: A Dynamic-Filter Query -- Builder Composes Without the Object Graph."""
from __future__ import annotations
from pypika import Field, Query, Table # => co-03: PyPika needs only a bare Table name, no mapped class required
from sqlalchemy import Engine, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SQLA_URL = "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example" # => used only to COMPILE the ORM statement below
class Base(DeclarativeBase): # => co-06: the ORM half NEEDS this registry root before any query can be built
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-06: a full mapped class -- the object graph this example contrasts against PyPika
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
country: Mapped[str] # => one of the two dynamic filter columns
age: Mapped[int] # => the other dynamic filter column
def build_dynamic_pypika(filters: dict[str, str | int]) -> str: # => co-26: filters composed with NO mapped class in sight
customer = Table("customer") # => co-26: just a NAME -- no DeclarativeBase, no registry, no import-time schema
query = Query.from_(customer).select(customer.id, customer.name) # => the base SELECT, before any dynamic predicate
for column, value in filters.items(): # => co-26: an ARBITRARY number of predicates, decided entirely at runtime
query = query.where(Field(column) == value) # => each iteration ANDs one more predicate onto the SAME tree
return str(query) # => renders the whole dynamically-built tree to SQL text on demand
def build_dynamic_orm(engine: Engine, filters: dict[str, str | int]) -> str: # => co-26: the SAME filters, via the ORM's mapped class
stmt = select(Customer.id, Customer.name) # => co-06: starts from the MAPPED CLASS, not a bare table name
for column, value in filters.items(): # => mirrors the builder loop exactly, for a fair comparison
stmt = stmt.where(getattr(Customer, column) == value) # => co-26: `getattr()` is needed because ORM columns are CLASS attributes
compiled = str(stmt.compile(engine, compile_kwargs={"literal_binds": True})) # => co-26: compiling REQUIRES the mapped class + an engine
return " ".join(compiled.split()) # => collapses SQLAlchemy's pretty-printed newlines to one line, for a clean comparison
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => needed ONLY so the ORM half has a dialect to compile against -- never connects
filters: dict[str, str | int] = {"country": "US", "age": 18} # => the SAME runtime-decided filter set for both builders
pypika_sql = build_dynamic_pypika(filters) # => co-26: zero setup beyond a bare Table() -- no class, no registry
orm_sql = build_dynamic_orm(engine, filters) # => co-26: requires the FULL Customer mapped class to already exist
print(f"pypika: {pypika_sql}") # => Output: pypika: SELECT "id","name" FROM "customer" WHERE "country"='US' AND "age"=18
print(f"orm: {orm_sql}") # => Output: orm: SELECT customer.id, customer.name FROM customer WHERE customer.country = 'US' AND customer.age = 18
# => co-26: same clean single-line rendering as the PyPika half, once SQLAlchemy's pretty-printed newlines collapse
assert "country" in pypika_sql and "country" in orm_sql # => co-26: both correctly compose the SAME dynamic filter set
assert "age" in pypika_sql and "age" in orm_sql # => co-26: both correctly compose the SECOND dynamic filter too
# => co-26: PyPika needed ONE line (a bare Table name) before it could build ANY query -- SQLAlchemy's ORM half
# => needed a full DeclarativeBase + Mapped[] class definition FIRST; that upfront cost buys the ORM its identity
# => map and change tracking, but a query builder skips it entirely when all you want is composable, safe SQL
print("ex-66 OK") # => Output: ex-66 OKRun: python3 example.py
Output:
pypika: SELECT "id","name" FROM "customer" WHERE "country"='US' AND "age"=18
orm: SELECT customer.id, customer.name FROM customer WHERE customer.country = 'US' AND customer.age = 18
ex-66 OKKey takeaway: PyPika composes an arbitrary number of runtime predicates onto a bare Table() name
with no upfront declaration; the ORM requires a full mapped class to already exist before getattr()
can even reference a column as a class attribute.
Why it matters: Dynamic filtering -- search forms, admin panels, API query parameters -- is exactly the query builder's home turf: composable, injection-safe SQL with no object-graph ceremony. Reaching for the ORM's mapped class here pays a real setup cost for identity-map and change-tracking features this kind of read-only, ad hoc query never uses.
Example 67: Query Builder vs ORM Tradeoff
ex-67 · exercises co-26
The same row, queried twice through PyPika's plain DB-API path and twice through the ORM's Session.
row_first is row_second is False for the builder (two fresh tuples), but obj_first is obj_second
is True for the ORM -- the identity map deduplicated by primary key within one session.
learning/code/ex-67-querybuilder-vs-orm-tradeoff/example.py
# pyright: strict
"""Example 67: Builder (No Identity Map) vs ORM (Change Tracking) -- a Feature/Cost Table, Demonstrated."""
from __future__ import annotations
import os # => reads connection settings from the environment
import psycopg # => co-26: the builder half executes via the plain DB-API -- no identity map layer at all
import psycopg.sql # => wraps a RUNTIME str as a Composable -- psycopg's stubs require this over a plain non-literal str
from pypika import Query, Table # => co-03: PyPika builds the SQL text, psycopg runs it
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
PG_DSN: str = os.environ.get("PG_DSN", "postgresql://postgres:postgres@localhost:5432/orm_by_example") # => a plain DB-API DSN
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: the ORM half's registry root -- the builder half needs NO equivalent
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-06: a full mapped class -- the identity map keys off THIS class + primary key
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, used only by the ORM half below
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE customer from Customer's Mapped[] fields
with Session(engine) as session: # => seeds exactly one row, shared by both halves below
session.add(Customer(name="Ada")) # => the single row both the builder AND the ORM will query TWICE
session.commit() # => flushes the INSERT
customer_table = Table("customer") # => co-26: a bare table name -- no class, no identity map possible
rendered = str(Query.from_(customer_table).select(customer_table.id, customer_table.name)) # => rendered once, run twice below
builder_sql = psycopg.sql.SQL(rendered) # pyright: ignore[reportArgumentType] # => wraps a runtime str -- not a literal, hence the escape hatch
with psycopg.connect(PG_DSN) as conn, conn.cursor() as cur: # => co-26: plain DB-API -- every fetch returns a FRESH tuple
cur.execute(builder_sql) # => first execution of the builder's SQL
row_first = cur.fetchone() # => co-26: a brand-new Python tuple, built fresh from THIS result set
cur.execute(builder_sql) # => second execution of the SAME SQL -- no caching, no dedup, by design
row_second = cur.fetchone() # => co-26: ANOTHER brand-new tuple -- nothing links it to the first one
builder_same_object = row_first is row_second # => co-26: expected False -- the builder path has no identity map at all
with Session(engine) as session: # => a fresh session -- the ORM half's identity map starts empty
obj_first = session.execute(select(Customer)).scalars().one() # => first load of the row, becomes a tracked object
obj_second = session.execute(select(Customer)).scalars().one() # => co-10: SAME primary key, SAME session -- identity map kicks in
orm_same_object = obj_first is obj_second # => co-10 + co-26: expected True -- the ORM deduplicates by primary key
# => co-26: both halves queried the SAME row twice, in the SAME process -- the only variable is which layer sat between the driver and your code
print(f"builder_same_object={builder_same_object}") # => Output: builder_same_object=False
print(f"orm_same_object={orm_same_object}") # => Output: orm_same_object=True
assert builder_same_object is False # => co-26: the builder's plain rows carry NO shared identity -- two fetches, two tuples
assert orm_same_object is True # => co-26: the ORM's identity map turned two loads into ONE shared Python object
# => co-26: this IS the feature/cost table, demonstrated rather than printed -- the builder pays NOTHING for
# => identity map bookkeeping (no per-row tracking, no session, no mapped class) but also gets NOTHING back;
# => the ORM pays setup cost (a DeclarativeBase, a mapped class, a Session) to buy deduplication AND automatic
# => dirty-tracking (co-12) on that SAME object -- reach for the ORM specifically WHEN that tracking earns its keep
print("ex-67 OK") # => Output: ex-67 OKRun: python3 example.py
Output:
builder_same_object=False
orm_same_object=True
ex-67 OKKey takeaway: The query builder's plain DB-API rows carry no shared identity across two fetches; the ORM's identity map turns two loads of the same primary key, within one session, into the identical Python object.
Why it matters: This is the feature/cost table made concrete instead of listed as prose. The
builder pays nothing for identity-map bookkeeping and gets nothing back; the ORM pays real setup cost
(a DeclarativeBase, a mapped class, a Session) to buy deduplication and automatic dirty tracking on
that same object. Reach for the ORM specifically when that tracking earns its keep, not by default.
Example 68: Choosing Tier CRUD
ex-68 · exercises co-27
An ordered rubric (choose_tier()) checks is_set_oriented, then is_latency_critical, then
is_object_shaped, in that order. A "customer profile edit" workload -- object-shaped, single-row,
not latency-critical -- lands on orm, with the rationale naming identity map and change tracking as
the specific payoff.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC -- color-blind friendly, WCAG AA
flowchart TD
A["workload: customer profile edit"]:::blue
A --> B{"is_set_oriented?"}:::orange
B -->|no| C{"is_latency_critical?"}:::orange
C -->|no| D{"is_object_shaped?"}:::teal
D -->|yes| E["tier: orm<br/>identity map + change tracking pay off"]:::purple
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-68-choosing-tier-crud/example.py
# pyright: strict
"""Example 68: A CRUD Workload -- the ORM Recommendation, With Rationale."""
from __future__ import annotations
from dataclasses import dataclass # => a typed record for the workload's characteristics, not a loose dict
@dataclass(frozen=True) # => co-27: immutable -- a workload description shouldn't mutate mid-decision
class Workload: # => co-27: the SAME shape of record every choosing-tier example scores against
name: str # => a human label for what's being decided, e.g. "customer profile edit"
is_object_shaped: bool # => does the domain naturally look like objects with identity and relationships?
is_set_oriented: bool # => does the work aggregate/scan across MANY rows at once (reports, bulk jobs)?
is_latency_critical: bool # => does a single call need sub-millisecond, every-microsecond-counts overhead?
def choose_tier(w: Workload) -> tuple[str, str]: # => co-27: returns (tier, rationale) -- a decision AND why
if w.is_set_oriented: # => co-25: set-oriented work belongs to raw SQL, regardless of anything else
# => checked FIRST -- set-orientation overrides every other characteristic in this rubric
return "raw_sql", "set-oriented workload -- aggregation belongs in the database, not a Python loop"
if w.is_latency_critical: # => co-27: hot paths avoid the ORM's per-object overhead even when object-shaped
# => checked SECOND -- latency budget overrides object-shape once set-orientation is ruled out
return "query_builder", "latency-critical -- skip identity-map/change-tracking overhead per call"
if w.is_object_shaped: # => co-06 + co-25: THIS is the ORM's sweet spot -- single-object CRUD, not set ops
# => checked LAST among the "yes" branches -- only reached once the two override conditions are false
return "orm", "single-entity CRUD on an object-shaped domain -- identity map + change tracking pay for themselves"
# => the fallback -- none of the three signals fired, so default to the middle tier rather than either extreme
return "query_builder", "default: composable, injection-safe SQL without paying for machinery you won't use"
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
crud = Workload( # => co-27: a CONCRETE CRUD scenario -- editing one customer's profile fields
name="customer profile edit",
is_object_shaped=True, # => a Customer with a name/email/address IS naturally an object
is_set_oriented=False, # => touches exactly ONE row per request, never a bulk scan
is_latency_critical=False, # => an admin-panel edit, not a request on the hot path
)
tier, rationale = choose_tier(crud) # => co-27: runs the SAME rubric every choosing-tier example uses
# => co-27: the rubric is a deliberate ORDERED decision tree, not a scoring/weighting scheme -- easy to reason about
print(f"tier={tier}") # => Output: tier=orm
print(f"rationale={rationale}") # => Output: rationale=single-entity CRUD on an object-shaped domain -- identity map + change tracking pay for themselves
assert tier == "orm" # => co-27: single-object CRUD on an object-shaped domain lands squarely on the ORM
assert "identity map" in rationale or "change tracking" in rationale # => the rationale names the SPECIFIC ORM feature that earns its keep
# => co-27: swap ANY one characteristic and the decision changes -- set is_set_oriented=True and it becomes raw
# => SQL (Example 69); set is_latency_critical=True and it drops to a query builder (Example 70) -- the tier
# => follows from the workload's SHAPE, not from habit or which library the team already knows
print("ex-68 OK") # => Output: ex-68 OKRun: python3 example.py
Output:
tier=orm
rationale=single-entity CRUD on an object-shaped domain -- identity map + change tracking pay for themselves
ex-68 OKKey takeaway: For a single-row, object-shaped, non-latency-critical workload, the ordered rubric
lands on orm, with a rationale naming the specific feature (identity map and change tracking) that
justifies the choice.
Why it matters: The rubric turns "just use the ORM" from habit into a reproducible decision: swap any one of the three characteristics and the recommendation changes predictably, as Examples 69 and 70 show. A workload's shape drives the tier, not which library the team already knows or prefers, and the SAME three questions apply just as well to a workload none of these three examples covers.
Example 69: Choosing Tier Analytics
ex-69 · exercises co-27
The identical rubric, applied to a "monthly revenue report" workload -- is_set_oriented=True short-
circuits the decision at the first check, before is_object_shaped is even evaluated, landing on
raw_sql regardless of the other two flags.
learning/code/ex-69-choosing-tier-analytics/example.py
# pyright: strict
"""Example 69: An Analytics Workload -- the Raw-SQL Recommendation, With Rationale."""
from __future__ import annotations
from dataclasses import dataclass # => a typed record for the workload's characteristics, not a loose dict
@dataclass(frozen=True) # => co-27: immutable -- a workload description shouldn't mutate mid-decision
class Workload: # => co-27: the SAME shape of record every choosing-tier example scores against (see Example 68)
name: str # => a human label for what's being decided, e.g. "monthly revenue report"
is_object_shaped: bool # => does the domain naturally look like objects with identity and relationships?
is_set_oriented: bool # => does the work aggregate/scan across MANY rows at once (reports, bulk jobs)?
is_latency_critical: bool # => does a single call need sub-millisecond, every-microsecond-counts overhead?
def choose_tier(w: Workload) -> tuple[str, str]: # => co-27: the SAME ordered rubric as Example 68 -- reused unchanged
if w.is_set_oriented: # => co-25: checked FIRST -- set-orientation overrides every other characteristic
# => this branch is exactly why THIS example's workload lands on raw SQL, not the other two branches
return "raw_sql", "set-oriented workload -- aggregation belongs in the database, not a Python loop"
if w.is_latency_critical: # => checked SECOND -- latency budget overrides object-shape once ruled out above
# => never reached for THIS workload -- the set-oriented check above already returned
return "query_builder", "latency-critical -- skip identity-map/change-tracking overhead per call"
if w.is_object_shaped: # => checked LAST among the "yes" branches -- the ORM's sweet spot, not reached here
# => never reached for THIS workload either -- an analytics report is never object-shaped
return "orm", "single-entity CRUD on an object-shaped domain -- identity map + change tracking pay for themselves"
# => the fallback -- unreachable for this workload, since the FIRST branch already returned
return "query_builder", "default: composable, injection-safe SQL without paying for machinery you won't use"
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
analytics = Workload( # => co-27: a CONCRETE analytics scenario -- a monthly revenue-by-region report
name="monthly revenue report",
is_object_shaped=False, # => a GROUP BY total is a NUMBER, not an object with identity
is_set_oriented=True, # => co-25: scans and aggregates potentially millions of order rows at once
is_latency_critical=False, # => a scheduled batch report, not a request on the hot path
)
tier, rationale = choose_tier(analytics) # => co-27: runs the SAME rubric as every choosing-tier example
# => co-27: the SAME ordered rubric, a DIFFERENT workload -- this is what makes the decision reproducible, not ad hoc
print(f"tier={tier}") # => Output: tier=raw_sql
print(f"rationale={rationale}") # => Output: rationale=set-oriented workload -- aggregation belongs in the database, not a Python loop
assert tier == "raw_sql" # => co-27: a set-oriented report lands on raw SQL, REGARDLESS of the other two flags
assert "aggregation" in rationale or "set-oriented" in rationale # => the rationale names the SPECIFIC reason, not a vague preference
# => co-27: notice `is_object_shaped=False` never even gets EVALUATED -- `is_set_oriented=True` short-circuits
# => the rubric at the FIRST check; Example 65 showed the concrete cost of ignoring this signal -- loading every
# => row into Python objects just to sum them in a for-loop, work Postgres' own GROUP BY does in one pass
print("ex-69 OK") # => Output: ex-69 OKRun: python3 example.py
Output:
tier=raw_sql
rationale=set-oriented workload -- aggregation belongs in the database, not a Python loop
ex-69 OKKey takeaway: is_set_oriented=True short-circuits the rubric at the first check -- the analytics
workload lands on raw_sql regardless of its other two characteristics, and is_object_shaped never
gets evaluated at all.
Why it matters: The same reusable rubric, applied to a different workload, produces a different and
correct recommendation -- proof it is reproducible rather than ad hoc. Example 65 already showed the
concrete cost of ignoring this signal: loading every row into Python just to sum it in a loop, work
Postgres' own GROUP BY does in one pass.
Example 70: Choosing Tier Hot Path
ex-70 · exercises co-27
The same rubric applied to a "product-lookup API endpoint" -- is_object_shaped=True, identical to
Example 68's workload, but is_latency_critical=True this time. The recommendation flips to
query_builder, the pairwise contrast that isolates exactly which flag changed the outcome.
learning/code/ex-70-choosing-tier-hot-path/example.py
# pyright: strict
"""Example 70: A Hot-Path Workload -- the Query-Builder/Raw Recommendation, With Rationale."""
from __future__ import annotations
from dataclasses import dataclass # => a typed record for the workload's characteristics, not a loose dict
@dataclass(frozen=True) # => co-27: immutable -- a workload description shouldn't mutate mid-decision
class Workload: # => co-27: the SAME shape of record every choosing-tier example scores against (see Example 68)
name: str # => a human label for what's being decided, e.g. "product-lookup API endpoint"
is_object_shaped: bool # => does the domain naturally look like objects with identity and relationships?
is_set_oriented: bool # => does the work aggregate/scan across MANY rows at once (reports, bulk jobs)?
is_latency_critical: bool # => does a single call need sub-millisecond, every-microsecond-counts overhead?
def choose_tier(w: Workload) -> tuple[str, str]: # => co-27: the SAME ordered rubric as Examples 68/69 -- reused unchanged
if w.is_set_oriented: # => co-25: checked FIRST -- set-orientation overrides every other characteristic
# => never reached for THIS workload -- a hot-path lookup touches one or a few rows, not a full scan
return "raw_sql", "set-oriented workload -- aggregation belongs in the database, not a Python loop"
if w.is_latency_critical: # => checked SECOND -- latency budget overrides object-shape once ruled out above
# => this branch is exactly why THIS example's workload lands on a query builder, not the ORM
return "query_builder", "latency-critical -- skip identity-map/change-tracking overhead per call"
if w.is_object_shaped: # => checked LAST among the "yes" branches -- the ORM's sweet spot, not reached here
# => never reached for THIS workload -- the latency check above already returned
return "orm", "single-entity CRUD on an object-shaped domain -- identity map + change tracking pay for themselves"
# => the fallback -- unreachable for this workload, since the SECOND branch already returned
return "query_builder", "default: composable, injection-safe SQL without paying for machinery you won't use"
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
hot_path = Workload( # => co-27: a CONCRETE hot-path scenario -- a product-lookup API endpoint under heavy load
name="product-lookup API endpoint",
is_object_shaped=True, # => a Product IS naturally an object -- this does NOT decide the tier alone
is_set_oriented=False, # => fetches ONE product per call, never a bulk scan
is_latency_critical=True, # => co-27: called thousands of times per second -- per-call overhead compounds
)
tier, rationale = choose_tier(hot_path) # => co-27: runs the SAME rubric as every choosing-tier example
# => co-27: three examples (68/69/70), one rubric -- reproducible decisions beat "the team already knows the ORM"
print(f"tier={tier}") # => Output: tier=query_builder
print(f"rationale={rationale}") # => Output: rationale=latency-critical -- skip identity-map/change-tracking overhead per call
assert tier == "query_builder" # => co-27: latency-critical wins over object-shape -- object-shape alone is NOT sufficient
assert "latency" in rationale or "overhead" in rationale # => the rationale names the SPECIFIC reason, not a vague preference
# => co-27: THIS is the pairwise contrast with Example 68 -- identical `is_object_shaped=True`, but flipping
# => `is_latency_critical` to True changes the recommendation from "orm" to "query_builder"; the ORM's identity
# => map and change tracking are real per-call costs, worth paying for CRUD convenience but not on a hot path
print("ex-70 OK") # => Output: ex-70 OKRun: python3 example.py
Output:
tier=query_builder
rationale=latency-critical -- skip identity-map/change-tracking overhead per call
ex-70 OKKey takeaway: With is_object_shaped=True held constant from Example 68, flipping only
is_latency_critical to True flips the recommendation from orm to query_builder -- object-shape
alone is not sufficient to justify the ORM's per-call overhead.
Why it matters: This pairwise contrast pins down exactly which characteristic drives the decision. The ORM's identity map and change tracking are real per-call costs, well worth paying for CRUD convenience but not on a hot path called thousands of times per second, where a query builder delivers the same safety without the overhead.
Example 71: Hybrid ORM Plus Raw
ex-71 · exercises co-25, co-27
The everyday CRUD path (create_product()) uses the ORM to build and commit mapped Product objects.
apply_holiday_discount() drops to session.execute(text(...)) on the same session for a set-based
10% price cut -- no second engine, no second connection, one transaction boundary shared by both tiers.
%% Color Palette: Blue #0173B2, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart TD
A["ONE Session, ONE transaction"]:::blue
A --> B["create_product()<br/>ORM -- mapped Product objects"]:::blue
A --> C["apply_holiday_discount()<br/>session.execute(text(...))"]:::teal
B --> D["session.commit()<br/>both tiers' writes together"]:::blue
C --> D
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-71-hybrid-orm-plus-raw/example.py
# pyright: strict
"""Example 71: ORM for CRUD + a Raw-SQL Escape Hatch -- Both in One App, One Session."""
from __future__ import annotations
import os # => reads connection settings from the environment
from typing import Any, cast # => cast() narrows session.execute()'s generic Result down to the CursorResult text() actually returns
from sqlalchemy import CursorResult, Engine, create_engine, select, text # => co-25: text() is the escape hatch INTO raw SQL
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Product(Base): # => co-06: the ORM half's mapped class, used for the everyday CRUD path
__tablename__ = "product" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
price_cents: Mapped[int] # => cents, not a float, to avoid rounding drift (co-05 spirit)
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Product's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE product from Product's Mapped[] fields
def create_product(session: Session, name: str, price_cents: int) -> Product: # => co-25: the ORM CRUD path -- everyday, object-shaped
product = Product(name=name, price_cents=price_cents) # => builds the mapped object in memory
session.add(product) # => registers it with the unit of work
session.commit() # => flushes the INSERT and assigns the auto-generated id
return product # => co-25: a normal ORM object, ready for the REST of the app to use as an object
def apply_holiday_discount(session: Session, percent: int) -> int: # => co-25 + co-27: the RAW-SQL escape hatch, on the SAME session
stmt = text("UPDATE product SET price_cents = price_cents * (100 - :pct) / 100") # => a set-based, server-side calculation
result = cast(CursorResult[Any], session.execute(stmt, {"pct": percent})) # => co-25: session.execute(text()) runs raw SQL THROUGH the ORM's connection
session.commit() # => co-25: the SAME session, the SAME transaction boundary as the ORM path above
return result.rowcount # => co-25: how many rows the raw statement touched -- a fact the ORM's object API can't give this cheaply
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, shared by BOTH the ORM and raw-SQL paths
reset_schema(engine) # => fresh, empty product table
with Session(engine) as session: # => ONE session, used for BOTH the ORM creates AND the raw-SQL bulk update below
create_product(session, "Widget", 1000) # => co-25: everyday CRUD -- the ORM path, one object at a time
create_product(session, "Gadget", 2000) # => a second product, same object-shaped path
rows_touched = apply_holiday_discount(session, percent=10) # => co-25 + co-27: the escape hatch -- a set-based 10% cut, no loop
prices = sorted(session.execute(select(Product.price_cents)).scalars().all()) # => co-25: back to the ORM's own select() to verify
# => co-27: notice apply_holiday_discount() takes the SAME `Session` object -- no second engine, no second connection
print(f"rows_touched={rows_touched}") # => Output: rows_touched=2
print(f"prices={prices}") # => Output: prices=[900, 1800]
assert rows_touched == 2 # => co-25: the raw UPDATE touched BOTH products in one statement, no per-object loop
assert prices == [900, 1800] # => co-25: 1000 -> 900 and 2000 -> 1800 -- a correct 10% cut, computed by Postgres itself
# => co-25 + co-27: this is the hybrid pattern in practice -- the ORM handles per-object CRUD where its leverage
# => helps, and `session.execute(text(...))` drops to raw SQL for the set-oriented operation it would otherwise
# => be awkward at, WITHOUT opening a second connection or leaving the current transaction -- one session, two tiers
print("ex-71 OK") # => Output: ex-71 OKRun: python3 example.py
Output:
rows_touched=2
prices=[900, 1800]
ex-71 OKKey takeaway: session.execute(text(...)) drops to raw SQL through the exact same Session an
ORM CRUD path already uses -- no second engine, no second connection, same transaction boundary.
Why it matters: The tier-choosing rubric from Examples 68-70 does not force an all-or-nothing choice per application. Most real systems use the ORM for the CRUD paths where it earns its keep and reach for a raw-SQL escape hatch on the same session for the specific set-oriented operation the ORM would otherwise handle awkwardly -- exactly the pattern this example runs.
Example 72: Identity Map Across Queries
ex-72 · exercises co-10
One customer, reached two structurally different ways within the same session: a direct select(Customer).where(Customer.id == 1), and a lazy-loaded order.customer navigated from an unrelated Order query. direct is via_relationship is True -- the identity map does not care how a row was reached.
learning/code/ex-72-identity-map-across-queries/example.py
# pyright: strict
"""Example 72: The Identity Map Dedups Across TWO DIFFERENT Queries -- Not Just a Repeated One."""
from __future__ import annotations
import os # => reads connection settings from the environment
from sqlalchemy import Engine, ForeignKey, create_engine, select, text # => co-10: select() is query SHAPE ONE below
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship # => relationship() enables query SHAPE TWO
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-10: the class the identity map keys off, by primary key, within a session
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
orders: Mapped[list[Order]] = relationship(back_populates="customer") # => reached via the SECOND, different query shape
class Order(Base): # => co-08: the "many" side -- used only to give the second query a DIFFERENT shape
__tablename__ = "order_table" # => named to avoid colliding with the SQL reserved word ORDER
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id")) # => the FK column backing the relationship
customer: Mapped[Customer] = relationship(back_populates="orders") # => the reverse navigation, exercised below
def reset_schema(engine: Engine) -> None: # => shared reset helper -- wipes the whole schema, self-contained
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Customer/Order into
Base.metadata.create_all(engine) # => issues CREATE TABLE from both models' Mapped[] fields
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine
reset_schema(engine) # => fresh, empty schema
with Session(engine) as session: # => seeds ONE customer with ONE order -- both queries below reach the SAME customer row
session.add(Customer(name="Ada", orders=[Order()])) # => builds the whole graph in one call
session.commit() # => flushes both the customer and order INSERTs
with Session(engine) as session: # => a FRESH session -- the identity map starts empty for this run
direct = session.execute(select(Customer).where(Customer.id == 1)).scalars().one() # => co-10: query SHAPE ONE -- a direct PK lookup
order = session.execute(select(Order)).scalars().one() # => loads the Order row itself, a COMPLETELY different query
via_relationship = order.customer # => co-10: query SHAPE TWO -- reached by NAVIGATING a relationship, not a fresh select()
same_object = direct is via_relationship # => co-10: the CRITICAL check -- did the identity map dedup ACROSS these two shapes?
print(f"same_object={same_object}") # => Output: same_object=True
print(f"name_via_relationship={via_relationship.name}") # => Output: name_via_relationship=Ada
assert same_object is True # => co-10: a DIRECT select AND a relationship-navigation lazy load returned the IDENTICAL Python object
# => co-10: the identity map does not care HOW a row was reached -- a top-level select(), a lazy-loaded relationship
# => (co-13), or a JOIN would all resolve to the SAME tracked object for the SAME primary key, within ONE session;
# => this is what makes co-12's dirty-tracking safe -- there's never more than one in-memory copy to go stale
print("ex-72 OK") # => Output: ex-72 OKRun: python3 example.py
Output:
same_object=True
name_via_relationship=Ada
ex-72 OKKey takeaway: A direct select() lookup and a relationship-navigation lazy load, reached through
completely unrelated queries within the same session, resolve to the identical Python object for the
same primary key.
Why it matters: This is what makes dirty tracking (Example 32) safe in the first place -- there is
never more than one in-memory copy of a row to go stale or diverge. Whether a row arrives via a
top-level select(), a lazy-loaded relationship, or a join, the identity map guarantees exactly one
tracked object per primary key per session.
Example 73: Relationship Lazy Strategy Config
ex-73 · exercises co-14
relationship(back_populates="customer", lazy="selectin") sets the default loading strategy right in
the class definition -- every plain select(Customer) at any call site now eager-loads .orders
automatically, with no .options(selectinload(...)) needed. The measured query count is 2, matching
Example 37's explicit version.
learning/code/ex-73-relationship-lazy-strategies-config/example.py
# pyright: strict
"""Example 73: Configuring the DEFAULT Lazy Strategy Per Relationship -- No Per-Query .options() Needed."""
from __future__ import annotations
import os # => reads connection settings from the environment
from collections.abc import Generator # => the modern return-type annotation @contextmanager expects, not Iterator
from contextlib import contextmanager # => co-15: a reusable "count queries in this block" helper, reused from Example 42
from typing import Any # => types SQLAlchemy's own untyped event-hook callback arguments
from sqlalchemy import Engine, ForeignKey, create_engine, event, select, text # => co-15: event is the counting mechanism
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship # => co-14: relationship(lazy=...) is the config knob
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-13 + co-14: the relationship below carries its OWN default strategy, set once, here
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
orders: Mapped[list[Order]] = relationship(back_populates="customer", lazy="selectin") # => co-14: CONFIGURED, not per-query
class Order(Base): # => the child every plain select(Customer) below implicitly eager-loads, with no .options() call
__tablename__ = "order_table" # => named to avoid colliding with the SQL reserved word ORDER
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id")) # => the FK column backing the relationship
customer: Mapped[Customer] = relationship(back_populates="orders") # => the reverse navigation, still plain-lazy by default
def reset_and_seed(engine: Engine, n: int) -> None: # => shared setup -- fresh schema, N customers, one order each
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fresh state for each measurement
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build both tables into
Base.metadata.create_all(engine) # => issues CREATE TABLE for both customer and order_table
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
for i in range(n): # => seeds N customers, one order each
session.add(Customer(name=f"Customer{i}", orders=[Order()])) # => a Customer plus exactly one child Order
session.commit() # => flushes all rows before this measurement's own counter starts
@contextmanager # => co-15: turns "count every SELECT fired inside this block" into a plain `with` statement
def query_counter(engine: Engine) -> Generator[list[int]]: # => yields a one-element mutable box holding the running count
box = [0] # => a list, not a plain int -- the caller reads box[0] AFTER the block exits, still seeing live updates
def on_execute(conn: Any, cursor: Any, statement: str, *rest: Any) -> None: # => untyped hook params (SQLAlchemy's own)
if statement.strip().upper().startswith("SELECT"): # => this counter only cares about read traffic
box[0] += 1 # => increments the SAME box the caller holds a reference to
listener = event.listens_for(engine, "before_cursor_execute")(on_execute) # => attaches for the block's duration
try: # => the caller's code runs HERE, between attach and detach
yield box # => hands the box to the `with` block -- readable both during and after
finally: # => detaches even if the caller's block raises
event.remove(engine, "before_cursor_execute", listener) # => cleanup -- the NEXT measurement starts at zero
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine
n_customers = 5 # => enough parents to make a per-object N+1 visibly different from a constant-2 query count
reset_and_seed(engine, n_customers) # => fresh workload for this measurement
with query_counter(engine) as count: # => co-14: measures a PLAIN select(Customer), NO .options() at the call site
with Session(engine) as session: # => a FRESH session -- nothing cached
customers = session.execute(select(Customer)).scalars().all() # => query #1 -- the configured lazy="selectin" fires HERE too
for customer in customers: # => touching `.orders` costs NOTHING new -- it was already batch-loaded
_ = [order.id for order in customer.orders] # => co-14: reads from memory, zero additional round trips
print(f"query_count={count[0]}") # => Output: query_count=2
assert count[0] == 2 # => co-14: exactly 2 queries -- the SAME shape as Example 37's explicit selectinload(), for free
# => co-14: `lazy="selectin"` in the relationship() DEFINITION changes the DEFAULT for EVERY query against this
# => class -- no caller needs to remember `.options(selectinload(...))`; the trade-off is that EVERY access to
# => `.orders` now eager-loads, even in code paths that never touch the relationship, so choose it deliberately
print("ex-73 OK") # => Output: ex-73 OKRun: python3 example.py
Output:
query_count=2
ex-73 OKKey takeaway: relationship(lazy="selectin") changes the default loading strategy for the whole
class -- every plain select() against it eager-loads automatically, without any .options() call at
the query site.
Why it matters: This is the class-level counterpart to Examples 36-38's per-query .options()
calls. Setting the default is convenient exactly when a relationship is almost always needed, but the
trade-off is real: every access, even in code paths that never touch the relationship, now pays the
eager-load cost -- choose the class-level default deliberately, not as a blanket habit.
Example 74: Self Referential Relationship
ex-74 · exercises co-08
One Category table with a self-pointing parent_id foreign key models an entire tree.
relationship(back_populates="parent", remote_side=[id]) on the parent side tells SQLAlchemy which end
of the self-FK is the "one," giving .children (downward) and .parent (upward) navigation from a
single class.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart TD
A["Electronics<br/>(root, parent_id=NULL)"]:::blue
B["Laptops<br/>(parent_id -> Electronics.id)"]:::orange
C["Phones<br/>(parent_id -> Electronics.id)"]:::teal
A -->|".children"| B
A -->|".children"| C
B -.".parent".-> A
C -.".parent".-> A
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-74-self-referential-relationship/example.py
# pyright: strict
"""Example 74: An Adjacency-List Self-FK Tree -- Parent/Child Navigation on ONE Table."""
from __future__ import annotations
import os # => reads connection settings from the environment
from sqlalchemy import ForeignKey, create_engine, select, text # => co-08: ForeignKey points the FK column BACK at its own table
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship # => relationship() twice, one class
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Category(Base): # => co-08: ONE table, but TWO relationship directions -- parent and children, both self-referential
__tablename__ = "category" # => the physical table name -- a single table models the whole tree
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
parent_id: Mapped[int | None] = mapped_column(ForeignKey("category.id")) # => co-08: an FK pointing BACK at its own table
children: Mapped[list[Category]] = relationship(back_populates="parent") # => co-08: the "one" side -- a parent's list of direct children
parent: Mapped[Category | None] = relationship( # => co-08: the "many" side -- remote_side marks the PARENT's `id` column
back_populates="children",
remote_side=[id], # => tells SQLAlchemy which end of the self-FK is the "one" in this pair
)
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build Category's table into
Base.metadata.create_all(engine) # => issues CREATE TABLE category, including the self-referential FK constraint
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
electronics = Category(name="Electronics") # => the ROOT of this small tree -- no parent
laptops = Category(name="Laptops", parent=electronics) # => co-08: `parent=` assigns the self-FK via the OBJECT, not an id
phones = Category(name="Phones", parent=electronics) # => a SECOND child, same parent, same pattern
session.add(electronics) # => cascades: adding the root also registers laptops and phones via the relationship
session.commit() # => flushes all three rows -- Electronics FIRST, then Laptops/Phones (co-12's dependency ordering)
with Session(engine) as session: # => a FRESH session -- nothing cached, navigation reloads from the database
root = session.execute(select(Category).where(Category.name == "Electronics")).scalars().one() # => loads the root row
child_names = sorted(c.name for c in root.children) # => co-08: navigates DOWN the tree via `.children`
laptop = session.execute(select(Category).where(Category.name == "Laptops")).scalars().one() # => loads a leaf row
parent_name = laptop.parent.name if laptop.parent is not None else None # => co-08: navigates UP the tree via `.parent`
# => co-08: `laptop.parent` above triggered its OWN lazy SELECT -- a self-referential relationship is still just
# => a relationship, subject to the SAME lazy/eager choices (co-13, co-14) as any other foreign-key mapping
print(f"child_names={child_names}") # => Output: child_names=['Laptops', 'Phones']
print(f"parent_name={parent_name}") # => Output: parent_name=Electronics
assert child_names == ["Laptops", "Phones"] # => co-08: downward navigation returns BOTH children, correctly ordered
assert parent_name == "Electronics" # => co-08: upward navigation returns the SAME root the children were built from
# => co-08: ONE table (`category`), ONE self-pointing foreign key (`parent_id`), and TWO relationship() directions
# => model an entire tree -- no separate parent table, no separate child table; this is the standard
# => adjacency-list pattern for hierarchies (org charts, comment threads, category trees) in a relational schema
print("ex-74 OK") # => Output: ex-74 OKRun: python3 example.py
Output:
child_names=['Laptops', 'Phones']
parent_name=Electronics
ex-74 OKKey takeaway: One self-pointing foreign key and two relationship() directions on a single class
model an entire tree -- remote_side=[id] is the piece of configuration that tells SQLAlchemy which end
of the self-FK is the "one" side.
Why it matters: This is the standard adjacency-list pattern for hierarchies -- org charts, comment
threads, category trees -- and it composes with everything already covered: .parent triggers its own
ordinary lazy load, subject to the same eager-loading and N+1 concerns (Examples 34-38) as any other
relationship, self-referential or not, so nothing learned earlier in this topic needs to be relearned here.
Example 75: Association Object M2M
ex-75 · exercises co-09
Enrollment is a full mapped class sitting on the student/course link table, with a composite
primary key and an extra grade column -- something a plain association table (Example 25) has no
natural place for. Loading Enrollment directly, then navigating .student and .course from it,
round-trips all three values correctly.
%% Color Palette: Blue #0173B2, Teal #029E73 -- color-blind friendly, WCAG AA
erDiagram
STUDENT ||--o{ ENROLLMENT : "enrollments"
COURSE ||--o{ ENROLLMENT : "enrollments"
STUDENT {
int id PK
string name
}
COURSE {
int id PK
string title
}
ENROLLMENT {
int student_id PK,FK
int course_id PK,FK
string grade
}
learning/code/ex-75-association-object-m2m/example.py
# pyright: strict
"""Example 75: An Association OBJECT -- Extra Columns on a Many-to-Many Link, Not Just Two Foreign Keys."""
from __future__ import annotations
import os # => reads connection settings from the environment
from sqlalchemy import ForeignKey, create_engine, select, text # => co-09: ForeignKey twice -- one per side of the M:N pair
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship # => relationship() links the association object both ways
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase
pass # => carries no columns -- purely a registry root
class Student(Base): # => co-09: one side of the M:N pair -- reached only THROUGH Enrollment, not a bare table
__tablename__ = "student" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
enrollments: Mapped[list[Enrollment]] = relationship(back_populates="student") # => co-09: navigates VIA the association object
class Course(Base): # => co-09: the other side of the M:N pair -- same pattern as Student
__tablename__ = "course" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
title: Mapped[str] # => a required TEXT column
enrollments: Mapped[list[Enrollment]] = relationship(back_populates="course") # => co-09: navigates VIA the association object
class Enrollment(Base): # => co-09: the ASSOCIATION OBJECT -- a real mapped class, not a bare link table like Example 25
__tablename__ = "enrollment" # => the physical M:N link table, but modeled as a full class
student_id: Mapped[int] = mapped_column(ForeignKey("student.id"), primary_key=True) # => half of the composite PK
course_id: Mapped[int] = mapped_column(ForeignKey("course.id"), primary_key=True) # => the other half of the composite PK
grade: Mapped[str] # => co-09: the EXTRA column a plain association table (Example 25) has no natural place for
student: Mapped[Student] = relationship(back_populates="enrollments") # => navigates back to the Student side
course: Mapped[Course] = relationship(back_populates="enrollments") # => navigates back to the Course side
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build all three tables into
Base.metadata.create_all(engine) # => issues CREATE TABLE for student, course, and the enrollment association object
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
ada = Student(name="Ada") # => the student half of the link this example creates
calculus = Course(title="Calculus") # => the course half of the link
enrollment = Enrollment(student=ada, course=calculus, grade="A") # => co-09: the EXTRA `grade` column, set right here
session.add(enrollment) # => cascades: adding the enrollment also registers ada and calculus
session.commit() # => flushes all three rows -- student, course, and the enrollment link WITH its grade
with Session(engine) as session: # => a FRESH session -- nothing cached, navigation reloads from the database
loaded = session.execute(select(Enrollment)).scalars().one() # => co-09: loads the association object ITSELF, not just a link row
student_name = loaded.student.name # => co-09: navigates FROM the association object TO the Student
course_title = loaded.course.title # => co-09: navigates FROM the association object TO the Course
grade = loaded.grade # => co-09: the EXTRA attribute -- persisted and reloaded, exactly like any other mapped column
print(f"student_name={student_name}") # => Output: student_name=Ada
print(f"course_title={course_title}") # => Output: course_title=Calculus
print(f"grade={grade}") # => Output: grade=A
assert (student_name, course_title, grade) == ("Ada", "Calculus", "A") # => co-09: all three round-tripped correctly
# => co-09: a plain association TABLE (Example 25) has no natural place for a per-link attribute like a grade,
# => an enrollment date, or a role -- an association OBJECT is a full mapped class sitting on that same link
# => table, so it can carry arbitrary extra columns AND be queried, updated, and navigated like any other entity
print("ex-75 OK") # => Output: ex-75 OKRun: python3 example.py
Output:
student_name=Ada
course_title=Calculus
grade=A
ex-75 OKKey takeaway: An association object is a full mapped class sitting on a many-to-many link table --
it carries a composite primary key like a plain association table, plus arbitrary extra columns like
grade, and can be queried and navigated like any other entity.
Why it matters: A plain association table (Example 25) is the right tool when a many-to-many link is a pure fact with no attributes of its own. The moment the link needs to carry data -- a grade, an enrollment date, a role -- an association object is the natural upgrade, without changing the underlying table's shape.
Example 76: Migration Zero Downtime
ex-76 · exercises co-19, co-21
Renaming a column safely takes two migrations, not one: an expand step (add contact_email, backfill
it from email, leave email untouched -- a currently-running old deploy keeps working) followed by a
contract step (drop email, run only after every deploy has cut over). Both stages are verified by
reading Postgres' own schema and data.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart LR
A["only email column"]:::blue
A -->|"EXPAND: add contact_email, backfill<br/>old deploy still works"| B["email AND contact_email<br/>both populated"]:::orange
B -->|"CONTRACT: drop email<br/>only after every deploy cut over"| C["only contact_email column"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-76-migration-zero-downtime/example.py
# pyright: strict
"""Example 76: Expand-Contract -- an ADDITIVE Step the Old App Survives, Then a Cleanup Step."""
from __future__ import annotations
import contextlib # => co-21: swallows alembic's own non-deterministic scaffolding chatter, same reason as Example 48
import io # => the throwaway buffer redirect_stdout writes that chatter INTO
import os # => reads connection settings, and builds/removes the scratch project directory
import shutil # => co-21: cleanup -- removes the scaffolded project directory once this example is done
import tempfile # => co-21: a fresh, self-contained directory for the scaffolded migration project
from alembic import command # => co-21: init/revision/upgrade -- the same API the alembic CLI itself calls
from alembic.config import Config # => co-21: every alembic.command function needs one of these as its first argument
from sqlalchemy import create_engine, text # => co-21: reads ACTUAL column values back at each stage of the rollout
# => co-19 + co-21: TWO migrations, chained by down_revision -- expand (0001) then contract (0002), never one big rename
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
ENV_PY = ( # => co-21: the SAME minimal env.py pattern from Examples 49-50 -- built as separate, individually-commented lines
"from alembic import context\n" # => co-21: `context` is alembic's OWN migration-runtime handle
"from sqlalchemy import engine_from_config, pool\n\n" # => co-21: builds a real Engine straight from alembic.ini's own section
"config = context.config\n" # => co-21: the SAME Config object bootstrap() constructed, now read back inside env.py
'target_metadata = config.attributes.get("target_metadata")\n\n\n' # => None -- these migrations are hand-written, not autogenerated
"def run_migrations_online() -> None:\n" # => co-21: THE function alembic's runtime calls for a live-connection migration
" connectable = engine_from_config(\n" # => co-21: reads the sqlalchemy.* keys straight out of alembic.ini's own section
' config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool\n' # => no reuse needed
" )\n" # => a short-lived migration run has no reason to KEEP a pooled connection open afterward
" with connectable.connect() as connection:\n" # => borrows ONE connection for the whole migration run
" context.configure(connection=connection, target_metadata=target_metadata)\n" # => co-21: binds THIS run's connection
" with context.begin_transaction():\n" # => co-21: wraps every upgrade() call in ONE transaction
" context.run_migrations()\n\n\n" # => the actual dispatch -- calls upgrade() on whichever revision is targeted
"run_migrations_online()\n" # => co-21: executed the MOMENT alembic imports this file -- no __main__ guard, by design
)
def bootstrap(url: str, project_dir: str) -> Config: # => co-21: init + env.py rewrite, shared setup for every Alembic example
script_dir = os.path.join(project_dir, "migrations") # => alembic's convention -- a "migrations" subfolder
cfg = Config(os.path.join(project_dir, "alembic.ini")) # => co-21: the Config object every command function needs
cfg.set_main_option("script_location", script_dir) # => tells Config where the scaffolded files live
cfg.set_main_option("sqlalchemy.url", url) # => co-21: the ONE connection string every migration runs against
with contextlib.redirect_stdout(io.StringIO()): # => swallows "Creating directory ... done" -- path varies per run
command.init(cfg, script_dir) # => co-21: scaffolds env.py, script.py.mako, README, versions/ (Example 48)
with open(os.path.join(script_dir, "env.py"), "w") as f: # => co-21: OVERWRITES the scaffolded env.py with our own
f.write(ENV_PY) # => a hand-edit step, exactly like a real project's post-init setup
return cfg # => ready for command.revision()/upgrade() calls
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, used to seed data AND verify each stage of the rollout
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema, about to get the PRE-rename table
conn.execute(text("CREATE TABLE person (id SERIAL PRIMARY KEY, email TEXT NOT NULL)")) # => co-21: the OLD column name
conn.execute(text("INSERT INTO person (email) VALUES ('ada@example.com')")) # => co-21: real data both steps must preserve
project_dir = tempfile.mkdtemp(prefix="alembic_ex76_") # => a fresh, throwaway migration project for this example
cfg = bootstrap(SQLA_URL, project_dir) # => scaffolds + rewires env.py, matching the earlier Alembic examples' setup
with contextlib.redirect_stdout(io.StringIO()): # => swallows "Generating .../0001_expand.py ... done"
expand_rev = command.revision(cfg, message="expand: add contact_email", rev_id="0001") # => co-21: STEP 1's skeleton
assert expand_rev is not None and not isinstance(expand_rev, list) # => narrows the Union return type for pyright --strict below
with open(expand_rev.path, "w") as f: # => co-21: EXPAND -- purely ADDITIVE, so a still-running old app instance keeps working
f.write( # => adds contact_email and backfills it, but leaves `email` untouched -- nothing old code depends on breaks
'"""expand: add contact_email"""\n' # => the migration's own docstring
"from typing import Sequence, Union\n" # => typed to match what command.revision()'s own skeleton generates
"from alembic import op\n" # => co-21: `op.add_column` -- the schema edit this step makes
"import sqlalchemy as sa\n\n" # => the column TYPE for the new contact_email column
'revision: str = "0001"\n' # => co-21: THIS revision's own id -- matches the rev_id passed to command.revision()
"down_revision: Union[str, Sequence[str], None] = None\n" # => None -- the first revision in this project
"branch_labels: Union[str, Sequence[str], None] = None\n" # => unused here -- relevant only for branching histories
"depends_on: Union[str, Sequence[str], None] = None\n\n" # => unused here -- cross-branch dependency declarations
"def upgrade() -> None:\n" # => co-21 + co-19: ADD then BACKFILL, never remove -- the additive half of the pattern
' op.add_column("person", sa.Column("contact_email", sa.String, nullable=True))\n' # => nullable UNTIL backfilled
" conn = op.get_bind()\n" # => co-21: op.get_bind() -- the LIVE connection this migration runs on, for row-level SQL
' conn.execute(sa.text("UPDATE person SET contact_email = email"))\n' # => co-21: copies every existing row's value across
' op.alter_column("person", "contact_email", nullable=False)\n' # => co-21: NOW safe to require -- every row is backfilled
" pass\n" # => co-21: `email` is DELIBERATELY untouched here -- the contract step, not this one, removes it
)
with contextlib.redirect_stdout(io.StringIO()): # => swallows alembic's own noisy status lines
command.upgrade(cfg, "head") # => co-21 + co-19: runs the EXPAND step -- migration 1 of 2
with engine.begin() as conn: # => a fresh connection to verify BOTH columns work at this midpoint
old_col = conn.execute(text("SELECT email FROM person")).scalar_one() # => co-21: the OLD app path -- still readable
new_col = conn.execute(text("SELECT contact_email FROM person")).scalar_one() # => co-21: the NEW app path -- already readable
# => co-19: THIS is the "verify the additive step" half of the pattern -- both column names resolve to the same value
print(f"after expand: email={old_col!r} contact_email={new_col!r}") # => Output: after expand: email='ada@example.com' contact_email='ada@example.com'
assert old_col == new_col == "ada@example.com" # => co-19 + co-21: an OLD deploy AND a NEW deploy can BOTH run against this schema
with contextlib.redirect_stdout(io.StringIO()): # => swallows "Generating .../0002_contract.py ... done"
contract_rev = command.revision(cfg, message="contract: drop email", rev_id="0002") # => co-21: STEP 2's skeleton
assert contract_rev is not None and not isinstance(contract_rev, list) # => narrows the Union return type for pyright --strict below
with open(contract_rev.path, "w") as f: # => co-21: CONTRACT -- runs ONLY after every app instance has cut over to contact_email
f.write( # => the cleanup half -- drops the now-unused `email` column, completing the rename without a hard cutover
'"""contract: drop email"""\n' # => the migration's own docstring
"from typing import Sequence, Union\n" # => typed to match what command.revision()'s own skeleton generates
"from alembic import op\n\n" # => co-21: `op.drop_column` -- the schema edit this step makes
'revision: str = "0002"\n' # => co-21: THIS revision's own id -- matches the rev_id passed to command.revision()
'down_revision: Union[str, Sequence[str], None] = "0001"\n' # => co-19: chains AFTER the expand step -- ordering matters
"branch_labels: Union[str, Sequence[str], None] = None\n" # => unused here -- relevant only for branching histories
"depends_on: Union[str, Sequence[str], None] = None\n\n" # => unused here -- cross-branch dependency declarations
"def upgrade() -> None:\n" # => co-21 + co-19: the CONTRACT half -- only safe once every reader has moved off `email`
' op.drop_column("person", "email")\n' # => co-21: removes the OLD column -- the rename is now truly complete
)
with contextlib.redirect_stdout(io.StringIO()): # => swallows alembic's own noisy status lines
command.upgrade(cfg, "head") # => co-21 + co-19: runs the CONTRACT step -- migration 2 of 2, the cleanup
with engine.begin() as conn: # => a fresh connection to verify the FINAL, post-cleanup shape
# => co-21: reads Postgres' OWN catalog table (information_schema), not application code -- the ground truth for the schema
columns = sorted(row[0] for row in conn.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'person'")))
print(f"after contract: columns={columns}") # => Output: after contract: columns=['contact_email', 'id']
assert columns == ["contact_email", "id"] # => co-21: `email` is GONE -- the two-step rollout finished with zero all-at-once cutover
# => co-19 + co-21: at NO point did a currently-running application break -- the expand step was purely additive
# => (any old deploy still reading `email` kept working), and the contract step ran only AFTER every deploy had
# => cut over to `contact_email`; a single one-shot "rename column" migration would have broken whichever app
# => version was mid-deploy the moment it ran -- expand-contract trades one migration for two, buying zero downtime
shutil.rmtree(project_dir) # => cleanup -- this example's own throwaway project, not a real repository
print("ex-76 OK") # => Output: ex-76 OKRun: python3 example.py
Output:
after expand: email='ada@example.com' contact_email='ada@example.com'
after contract: columns=['contact_email', 'id']
ex-76 OKKey takeaway: A safe column rename is two migrations, not one -- an additive expand step a running old deploy survives, followed by a contract step that runs only after every deploy has cut over, verified at each stage by reading Postgres' own data and schema.
Why it matters: A single one-shot "rename column" migration breaks whichever application version is mid-deploy the moment it runs, because old and new code can never simultaneously agree on a column name. Expand-contract trades one migration for two specifically to avoid that window -- the same reversibility discipline from Examples 53-54, applied to a rollout that spans multiple deploys instead of a single release.
Example 77: Connection Pool Tuning
ex-77 · exercises co-18
Eight concurrent workers, each holding a connection for 0.2 seconds, run against an undersized pool
(pool_size=2) and a correctly-tuned pool (pool_size=8). The undersized pool measures roughly 0.87
seconds (workers queue in waves); the tuned pool measures roughly 0.24 seconds (every worker gets a
connection immediately).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart TD
A["8 concurrent workers<br/>0.2s each"]:::blue
A --> B["pool_size=2<br/>undersized"]:::orange
A --> C["pool_size=8<br/>tuned"]:::teal
B --> D["~0.87s -- workers queue in waves"]:::orange
C --> E["~0.24s -- every worker gets a connection immediately"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-77-connection-pool-tuning/example.py
# pyright: strict
"""Example 77: Tuning the Pool for a Concurrency Target -- Measured Throughput, Not a Guess."""
from __future__ import annotations
import os # => reads connection settings from the environment
import time # => wall-clock timing -- the measured evidence, not an assumed claim
from concurrent.futures import ThreadPoolExecutor # => co-18: real concurrent WORKERS, each needing its own pooled connection
from sqlalchemy import Engine, create_engine, select, text # => co-18: pool_size/max_overflow are create_engine() keywords
from sqlalchemy.orm import Session # => a Session is the ORM's unit-of-work handle, one per worker below
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
def worker(engine: Engine) -> None: # => co-18: ONE simulated request -- checks out a connection, does 0.2s of DB work, returns it
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
session.execute(select(text("pg_sleep(0.2)"))) # => co-18: simulates a slow query -- 0.2s of server-side wait, per worker
def run_workload(engine: Engine, n_workers: int) -> float: # => co-18: launches n_workers CONCURRENT requests, times the batch
start = time.monotonic() # => wall-clock start, right before the concurrent workers launch
with ThreadPoolExecutor(max_workers=n_workers) as pool: # => co-18: n_workers THREADS, each wanting a connection AT THE SAME TIME
futures = [pool.submit(worker, engine) for _ in range(n_workers)] # => co-18: fires ALL workers concurrently, not one-by-one
for future in futures: # => waits for every worker to finish before measuring elapsed time
future.result() # => re-raises any worker exception -- a silent failure would corrupt the timing measurement
return time.monotonic() - start # => co-18: total wall-clock time for the WHOLE concurrent batch
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
n_workers = 8 # => co-18: the CONCURRENCY TARGET this example tunes the pool for -- 8 simultaneous requests
undersized_engine = create_engine(SQLA_URL, pool_size=2, max_overflow=0) # => co-18: WAY below the target -- only 2 connections
undersized_seconds = run_workload(undersized_engine, n_workers) # => co-18: 8 workers competing for just 2 connections
undersized_engine.dispose() # => closes every pooled connection -- good hygiene before building the next engine
tuned_engine = create_engine(SQLA_URL, pool_size=n_workers, max_overflow=0) # => co-18: sized to MATCH the concurrency target
tuned_seconds = run_workload(tuned_engine, n_workers) # => co-18: 8 workers, 8 connections -- no queueing for a free one
tuned_engine.dispose() # => closes every pooled connection -- good hygiene at process shutdown
print(f"undersized: {undersized_seconds:.2f}s") # => Output: undersized: <around 0.80s, varies by machine>
print(f"tuned: {tuned_seconds:.2f}s") # => Output: tuned: <around 0.20s, varies by machine>
print(f"tuned faster: {tuned_seconds < undersized_seconds}") # => Output: tuned faster: True
assert tuned_seconds < undersized_seconds # => co-18: the correctly-sized pool measurably beats the undersized one
assert tuned_seconds < 0.5 # => co-18: 8 workers x 0.2s should overlap into roughly ONE 0.2s window, not stack up serially
# => co-18: with pool_size=2, 8 workers queue in batches of 2 -- roughly FOUR sequential 0.2s waves, ~0.8s total;
# => with pool_size=8, all EIGHT workers get a connection immediately and their 0.2s waits overlap into ONE
# => window, ~0.2s total -- the pool size is a REAL throughput ceiling under concurrency, not a cosmetic setting
print("ex-77 OK") # => Output: ex-77 OKRun: python3 example.py
Output:
undersized: 0.87s
tuned: 0.24s
tuned faster: True
ex-77 OKKey takeaway: With 8 concurrent workers, pool_size=2 forces roughly four sequential waves of
queueing (~0.87s total); pool_size=8 lets every worker check out a connection immediately, so all
their 0.2-second waits overlap into one window (~0.24s total).
Why it matters: This measures Example 46's exhaustion story from the other direction: instead of showing what happens when the pool is too small, it shows the throughput actually recovered by sizing it correctly. The pool size is a real, measurable throughput ceiling under concurrency, worth tuning to the application's actual concurrent-request target rather than leaving at a default.
Example 78: Capstone Preview Three Tier
ex-78 · exercises co-01, co-15, co-19, co-25
One script threads four ideas from across this whole topic together: the identical query answered
through raw SQL, a query builder, and the ORM (all three agree); an N+1 reproduced then fixed with
selectinload (4 queries down to 2); and a schema change that both applies and rolls back cleanly. Every
claim is verified by running it, not asserted from memory.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC -- color-blind friendly, WCAG AA
flowchart TD
A["Same customer/order data"]:::blue
A --> B["Tier 1: raw SQL"]:::orange
A --> C["Tier 2: query builder"]:::teal
A --> D["Tier 3: ORM"]:::purple
B --> E["Identical answer"]:::blue
C --> E
D --> E
D --> F["N+1: 4 queries"]:::orange
F -->|"selectinload fix"| G["Fixed: 2 queries"]:::teal
A --> H["Migration: upgrade + downgrade"]:::purple
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-78-capstone-preview-three-tier/example.py
# pyright: strict
"""Example 78: Three Tiers + an N+1 Fix + a Migration -- End to End, in One Script."""
from __future__ import annotations
import contextlib # => co-19: swallows alembic's own non-deterministic scaffolding chatter, same reason as Example 48
import io # => the throwaway buffer redirect_stdout writes that chatter INTO
import os # => reads connection settings, and builds/removes the scratch project directory
import shutil # => co-19: cleanup -- removes the scaffolded project directory once this example is done
import tempfile # => co-19: a fresh, self-contained directory for the scaffolded migration project
from collections.abc import Generator # => the modern return-type annotation @contextmanager expects, not Iterator
from contextlib import contextmanager # => co-15: a reusable "count queries in this block" helper, reused from Example 42
from typing import Any # => types SQLAlchemy's own untyped event-hook callback arguments
import psycopg # => co-01: tier 1 -- the raw DB-API driver, no builder or ORM involved
from alembic import command # => co-19: init/revision/upgrade -- the same API the alembic CLI itself calls
from alembic.config import Config # => co-19: every alembic.command function needs one of these as its first argument
import psycopg.sql # => wraps a RUNTIME str as a Composable -- psycopg's stubs require this over a plain non-literal str
from pypika import Query, Table # => co-01: tier 2 -- PyPika builds the SQL text, psycopg runs it
from pypika import functions as pypika_fn # => co-03: PyPika's own COUNT() aggregate function, not a raw SQL fragment
from sqlalchemy import Engine, ForeignKey, create_engine, event, select, text # => co-01: tier 3 -- the ORM's own select()
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, selectinload # => co-14: selectinload is the N+1 fix
PG_DSN: str = os.environ.get("PG_DSN", "postgresql://postgres:postgres@localhost:5432/orm_by_example") # => a plain DB-API DSN
SQLA_URL: str = os.environ.get( # => the SQLAlchemy dialect+driver URL, distinct from a plain DB-API DSN
"SQLA_URL", "postgresql+psycopg://postgres:postgres@localhost:5432/orm_by_example"
) # => override SQLA_URL in the environment to point at a different Postgres instance
ENV_PY = ( # => co-19: the SAME minimal env.py pattern from Examples 49-50 -- built as separate, individually-commented lines
"from alembic import context\n" # => co-19: `context` is alembic's OWN migration-runtime handle
"from sqlalchemy import engine_from_config, pool\n\n" # => co-19: builds a real Engine straight from alembic.ini's own section
"config = context.config\n" # => co-19: the SAME Config object bootstrap() constructed, now read back inside env.py
'target_metadata = config.attributes.get("target_metadata")\n\n\n' # => None -- this migration is hand-written, not autogenerated
"def run_migrations_online() -> None:\n" # => co-19: THE function alembic's runtime calls for a live-connection migration
" connectable = engine_from_config(\n" # => co-19: reads the sqlalchemy.* keys straight out of alembic.ini's own section
' config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool\n' # => no reuse needed
" )\n" # => a short-lived migration run has no reason to KEEP a pooled connection open afterward
" with connectable.connect() as connection:\n" # => borrows ONE connection for the whole migration run
" context.configure(connection=connection, target_metadata=target_metadata)\n" # => co-19: binds THIS run's connection
" with context.begin_transaction():\n" # => co-19: wraps every upgrade()/downgrade() call in ONE transaction
" context.run_migrations()\n\n\n" # => the actual dispatch -- calls upgrade()/downgrade() on the revision below
"run_migrations_online()\n" # => co-19: executed the MOMENT alembic imports this file -- no __main__ guard, by design
)
class Base(DeclarativeBase): # => co-06: every mapped class in this program shares ONE DeclarativeBase (tier 3)
pass # => carries no columns -- purely a registry root
class Customer(Base): # => co-01: the SAME table all three tiers read, mapped only for tier 3
__tablename__ = "customer" # => the physical table name
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
name: Mapped[str] # => a required TEXT column
orders: Mapped[list[Order]] = relationship(back_populates="customer") # => co-15: the relationship the N+1 fires through
class Order(Base): # => co-01: the "many" side, joined against in ALL three tiers
__tablename__ = "order_table" # => named to avoid colliding with the SQL reserved word ORDER
id: Mapped[int] = mapped_column(primary_key=True) # => auto-assigned by Postgres
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id")) # => the FK column every tier's JOIN condition uses
customer: Mapped[Customer] = relationship(back_populates="orders") # => the reverse navigation, exercised in tier 3 below
def reset_and_seed(engine: Engine, n_customers: int) -> None: # => shared setup -- fresh schema, N customers, one order each
with engine.begin() as conn: # => begin(): auto-commits on a clean exit, auto-rolls-back on an exception
conn.execute(text("DROP SCHEMA public CASCADE")) # => wipes EVERY table -- fully isolated from other examples
conn.execute(text("CREATE SCHEMA public")) # => a blank public schema to build both tables into
Base.metadata.create_all(engine) # => issues CREATE TABLE for both customer and order_table
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
for i in range(n_customers): # => seeds N customers, one order each -- the SAME fixture data all three tiers read
session.add(Customer(name=f"Customer{i}", orders=[Order()])) # => a Customer plus exactly one child Order
session.commit() # => flushes all rows before any tier queries them
def tier1_raw_sql(dsn: str) -> list[tuple[str, int]]: # => co-01 + co-02: raw parameterized SQL, manual row unpacking
# => co-25: hand-written text, the SAME GROUP BY tier 2 and tier 3 below express in two OTHER ways
sql = "SELECT c.name, COUNT(o.id) FROM customer c LEFT JOIN order_table o ON o.customer_id = c.id GROUP BY c.id, c.name ORDER BY c.name"
with psycopg.connect(dsn) as conn, conn.cursor() as cur: # => co-02: a plain DB-API connection, no builder or ORM at all
cur.execute(sql) # => co-25: a set-oriented GROUP BY, computed entirely server-side, in ONE round trip
return [(name, count) for name, count in cur.fetchall()] # => co-02: manual tuple unpacking into a typed list
def tier2_query_builder(dsn: str) -> list[tuple[str, int]]: # => co-01 + co-03: PyPika composes the SAME query as data, not text
customer_t, order_t = Table("customer"), Table("order_table") # => co-03: bare table names -- no mapped class involved
q = (
Query.from_(customer_t) # => co-03: the SAME GROUP BY as tier 1, built as a composable tree instead of a string
.left_join(order_t) # => co-03: the LEFT JOIN itself -- a method call, not string concatenation
.on(order_t.customer_id == customer_t.id) # => co-03: the JOIN condition, expressed as Field comparisons
.groupby(customer_t.id, customer_t.name) # => co-03: composed onto the SAME tree, one method at a time
.orderby(customer_t.name) # => matches tier 1's ORDER BY, so the two result lists compare equal below
.select(customer_t.name, pypika_fn.Count(order_t.id)) # => co-03: PyPika's own COUNT() aggregate function
)
rendered = psycopg.sql.SQL(str(q)) # pyright: ignore[reportArgumentType] # => wraps a runtime str -- Example 67's escape-hatch pattern
with psycopg.connect(dsn) as conn, conn.cursor() as cur: # => co-01: executed via the SAME raw DB-API as tier 1
cur.execute(rendered) # => co-01: PyPika BUILT the query, but a plain DB-API cursor still RUNS it
return [(name, count) for name, count in cur.fetchall()] # => co-03: identical shape to tier 1's return value
def tier3_orm(engine: Engine) -> list[tuple[str, int]]: # => co-01 + co-06: the ORM tier -- object-shaped, in-Python aggregation
with Session(engine) as session: # => a Session is the ORM's unit-of-work handle (co-12)
stmt = select(Customer).options(selectinload(Customer.orders)) # => co-14: the FIX -- eager-loads BEFORE the N+1 could fire
customers = session.execute(stmt).scalars().all() # => query #1 (parents) + query #2 (batched children) -- co-15's fix
return sorted((c.name, len(c.orders)) for c in customers) # => co-25: aggregation done in Python -- correct, but hand-rolled
@contextmanager # => co-15: turns "count every SELECT fired inside this block" into a plain `with` statement
def query_counter(engine: Engine) -> Generator[list[int]]: # => yields a one-element mutable box holding the running count
box = [0] # => a list, not a plain int -- the caller reads box[0] AFTER the block exits, still seeing live updates
def on_execute(conn: Any, cursor: Any, statement: str, *rest: Any) -> None: # => untyped hook params (SQLAlchemy's own)
if statement.strip().upper().startswith("SELECT"): # => this counter only cares about read traffic
box[0] += 1 # => increments the SAME box the caller holds a reference to
listener = event.listens_for(engine, "before_cursor_execute")(on_execute) # => attaches for the block's duration
try: # => the caller's code runs HERE, between attach and detach
yield box # => hands the box to the `with` block -- readable both during and after
finally: # => detaches even if the caller's block raises
event.remove(engine, "before_cursor_execute", listener) # => cleanup -- the NEXT measurement starts at zero
def bootstrap_alembic(url: str, project_dir: str) -> Config: # => co-19: init + env.py rewrite, shared setup for the migration step
script_dir = os.path.join(project_dir, "migrations") # => alembic's convention -- a "migrations" subfolder
cfg = Config(os.path.join(project_dir, "alembic.ini")) # => co-19: the Config object every command function needs
cfg.set_main_option("script_location", script_dir) # => tells Config where the scaffolded files live
cfg.set_main_option("sqlalchemy.url", url) # => co-19: the ONE connection string the migration runs against
with contextlib.redirect_stdout(io.StringIO()): # => swallows "Creating directory ... done" -- path varies per run
command.init(cfg, script_dir) # => co-19: scaffolds env.py, script.py.mako, README, versions/ (Example 48)
with open(os.path.join(script_dir, "env.py"), "w") as f: # => co-19: OVERWRITES the scaffolded env.py with our own
f.write(ENV_PY) # => a hand-edit step, exactly like a real project's post-init setup
return cfg # => ready for command.revision()/upgrade()/downgrade() calls
if __name__ == "__main__": # => module entry point -- only runs when executed directly, not on import
engine = create_engine(SQLA_URL) # => an ORM-capable engine, shared by every tier and the migration step below
reset_and_seed(engine, n_customers=3) # => co-01: THE SAME fixture data all three tiers below independently read
sql_result = tier1_raw_sql(PG_DSN) # => co-01: tier 1 -- raw SQL
builder_result = tier2_query_builder(PG_DSN) # => co-01: tier 2 -- query builder
orm_result = tier3_orm(engine) # => co-01: tier 3 -- full ORM
print(f"sql_result={sql_result}") # => Output: sql_result=[('Customer0', 1), ('Customer1', 1), ('Customer2', 1)]
assert sql_result == builder_result == orm_result # => co-01: all three tiers agree on the IDENTICAL correct answer
# => co-01: this is the SPECTRUM from Example 1, run for real -- three DIFFERENT code paths, one CORRECT answer
with query_counter(engine) as before: # => co-15: reproduces the N+1 -- NO eager-loading option this time
with Session(engine) as session: # => a FRESH session -- nothing cached
for customer in session.execute(select(Customer)).scalars().all(): # => query #1
_ = [o.id for o in customer.orders] # => co-13: each access fires its OWN lazy round trip -- the N+1 itself
with query_counter(engine) as after: # => co-14: the SAME access pattern, now fixed with eager loading
with Session(engine) as session: # => a FRESH session -- nothing cached
stmt = select(Customer).options(selectinload(Customer.orders)) # => co-14: the fix, identical to tier3_orm()
for customer in session.execute(stmt).scalars().all(): # => query #1 (parents) + query #2 (batched children)
_ = [o.id for o in customer.orders] # => reads from memory -- zero additional round trips
print(f"before={before[0]} after={after[0]}") # => Output: before=4 after=2
assert before[0] == 4 and after[0] == 2 # => co-15: N+1 (3 customers + 1 parent query) collapses to a CONSTANT 2
# => co-15: this is Examples 36-37's fix, threaded through the SAME tier-3 code path tier3_orm() above already uses
project_dir = tempfile.mkdtemp(prefix="alembic_ex78_") # => a fresh, throwaway migration project for this example
cfg = bootstrap_alembic(SQLA_URL, project_dir) # => scaffolds + rewires env.py, matching the earlier Alembic examples
with contextlib.redirect_stdout(io.StringIO()): # => swallows "Generating .../0001_add_email.py ... done"
rev = command.revision(cfg, message="add email", rev_id="0001") # => co-19: an EMPTY skeleton, filled in below
assert rev is not None and not isinstance(rev, list) # => narrows the Union return type for pyright --strict below
with open(rev.path, "w") as f: # => co-19: a REVERSIBLE schema-only migration -- add a column, then drop it back
f.write( # => co-19: the SAME "built as separate, individually-commented string lines" pattern as Examples 49-54
'"""add email"""\n' # => the migration's own docstring
"from typing import Sequence, Union\n" # => typed to match what command.revision()'s own skeleton generates
"from alembic import op\n" # => co-19: `op.add_column`/`op.drop_column` -- the schema edits this migration makes
"import sqlalchemy as sa\n\n" # => the column TYPE for the new email column
'revision: str = "0001"\n' # => co-19: THIS revision's own id -- matches the rev_id passed to command.revision()
"down_revision: Union[str, Sequence[str], None] = None\n" # => None -- the first (and only) revision in this project
"branch_labels: Union[str, Sequence[str], None] = None\n" # => unused here -- relevant only for branching histories
"depends_on: Union[str, Sequence[str], None] = None\n\n" # => unused here -- cross-branch dependency declarations
"def upgrade() -> None:\n" # => co-19: FORWARD -- adds the column
' op.add_column("customer", sa.Column("email", sa.String, nullable=True))\n\n' # => co-19: the additive half
"def downgrade() -> None:\n" # => co-19: REVERSE -- removes the SAME column, restoring the original shape
' op.drop_column("customer", "email")\n' # => co-21: a TRUE reversal -- the schema round-trips exactly
)
with contextlib.redirect_stdout(io.StringIO()): # => swallows alembic's own noisy status lines
command.upgrade(cfg, "head") # => co-19: runs the forward migration
with engine.begin() as conn: # => a fresh connection to verify the column now EXISTS
after_up = conn.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'customer'")).all()
with contextlib.redirect_stdout(io.StringIO()): # => swallows alembic's own noisy status lines
command.downgrade(cfg, "base") # => co-19 + co-21: rolls the migration back
with engine.begin() as conn: # => a fresh connection to verify the column is now GONE again
after_down = conn.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'customer'")).all()
has_email_after_up = "email" in {row[0] for row in after_up} # => co-19: confirms the forward step actually added the column
has_email_after_down = "email" in {row[0] for row in after_down} # => co-21: confirms the rollback actually removed it again
# => co-19 + co-21: THIS is the "verify migrate-then-rollback returns the schema to its starting state" bar
print(f"has_email_after_up={has_email_after_up}") # => Output: has_email_after_up=True
print(f"has_email_after_down={has_email_after_down}") # => Output: has_email_after_down=False
assert has_email_after_up is True # => co-19: the migration applied cleanly
assert has_email_after_down is False # => co-21: the migration rolled back cleanly, restoring the original schema
# => co-19: forward and rollback are BOTH exercised, not just the forward half -- an untested downgrade() is a liability
# => co-01 + co-15 + co-19 + co-25: this IS the capstone's shape, in miniature -- the same domain queried through
# => raw SQL, a query builder, and a full ORM (co-01), an N+1 reproduced then fixed with eager loading (co-15),
# => and a schema change that both applies and rolls back correctly (co-19, co-21) -- every tier's trade-off
# => demonstrated by running it, not asserted from memory; the full capstone develops this same shape further
shutil.rmtree(project_dir) # => cleanup -- this example's own throwaway project, not a real repository
# => 78 examples, 27 concepts, one running theme -- pick the tier the WORKLOAD demands, not the tier you know best
print("ex-78 OK") # => Output: ex-78 OKRun: python3 example.py
Output:
sql_result=[('Customer0', 1), ('Customer1', 1), ('Customer2', 1)]
before=4 after=2
has_email_after_up=True
has_email_after_down=False
ex-78 OKKey takeaway: One script proves four claims by running them: three tiers agree on the identical
answer, an N+1 collapses from 4 queries to 2 with selectinload, and a migration both applies and rolls
back cleanly -- every trade-off this topic covers, demonstrated together instead of asserted separately.
Why it matters: This is the topic's own claim made good on: 78 examples, 27 concepts, one running theme -- pick the tier the workload demands, not the tier the team already knows, and verify every claim by executing it against real Postgres rather than trusting a guess. This script previews the shape a fuller capstone project would develop further, threading raw SQL, a query builder, the ORM, N+1 detection and fixing, and reversible migrations through one coherent, runnable example.
← Previous: Intermediate Examples · Next: Capstone →
Last updated July 17, 2026