Overview
Prerequisites
- Prior topics: 10 · SQL Essentials -- writing joins,
GROUP BY, and the Pythonsqlite3DB-API this topic assumes you already know; 26 · Advanced SQL & Query Performance -- reading anEXPLAINplan and how indexes change one, content this topic leans on throughout. - Tools & environment: a macOS/Linux terminal; Python 3.x, fully type-annotated (DD-39); a
local PostgreSQL instance (a container is the easiest path); the DB-API driver (psycopg, PEP 249), a
query builder (PyPika), a Data-Mapper ORM (SQLAlchemy 2.0.x), an Active-Record ORM (peewee), and a
migration tool (Alembic) -- every version pinned and CVE-clean in this topic's own
requirements.txt; Neovim/VSCode with the Python LSP (DD-17);pyright --strictfor typechecking. - Assumed knowledge: writing joins and reading an
EXPLAINplan (topic 10); indexes and how they change a query plan (topic 26); reading a typed Python module and running a.pyscript (4 · Just Enough Python).
Why this exists -- the big idea
The problem before the solution: hand-writing SQL and mapping every result row to an object by hand is tedious and error-prone. Objects have identity, references, and inheritance; tables have rows, keys, and joins. That mismatch bred a generation of boilerplate mapping code, and it is exactly the gap the patterns in this topic -- Active Record, Data Mapper, Unit of Work, Identity Map -- were named to close.
Keep-this-if-you-forget-everything: an ORM is an abstraction over SQL, not a replacement for understanding it. It buys real productivity on CRUD and charges you the moment the query matters -- a report, a bulk job, a query with a shape the object model cannot express cleanly. Know which tier (raw SQL, query builder, or ORM) you are standing on for any given piece of code, and what that tier hides from you in exchange for its leverage.
Cross-cutting big ideas, taught here and then reused for the rest of this curriculum:
abstraction-and-its-cost -- each tier hides more SQL for more leverage, and the hidden SQL leaks back
out as the N+1 problem and the accidental full-table scan the moment you stop paying attention;
coupling-vs-cohesion -- a Data-Mapper/Session layer keeps persistence concerns cohesive and decoupled
from domain logic, instead of scattering hand-written SQL through the rest of the codebase.
Confirm your toolchain
Every example in this topic is a self-contained, fully type-annotated Python file colocated under
learning/code/, run against a real local PostgreSQL instance -- every printed value and query count
on this topic's pages is a genuine, captured transcript, never a guessed one:
$ python3 --version
Python 3.13.12
$ pip show sqlalchemy peewee pypika alembic psycopg pyright | grep -E "^(Name|Version)"
Name: SQLAlchemy
Version: 2.0.51
Name: peewee
Version: 4.2.6
Name: PyPika
Version: 0.51.1
Name: alembic
Version: 1.18.5
Name: psycopg
Version: 3.3.4
Name: pyright
Version: 1.1.411Set up once, from this topic's own directory:
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
docker run --name orm-by-example -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=orm_by_example -p 5432:5432 -d postgres:18.4-alpineEvery example's own defaults point at postgresql://postgres:postgres@localhost:5432/orm_by_example,
so once the container above is running, python3 learning/code/ex-01-.../example.py (or any other
example, run directly) works with no further configuration. Every example resets and seeds its own
tables on every run, so examples are safe to run in any order, repeatedly. pyright (strict mode, via
this topic's own pyrightconfig.json) and ruff format --check both run clean across every example.
How this topic's examples are organized
- Beginner (Examples 1-28) -- the raw PEP 249 DB-API (connect, cursor,
parameterized queries, typed row mapping,
executemany, transactions), PyPika as a standalone query builder (SELECT, composedWHERE,JOIN, injection safety), SQLAlchemy Core's own builder (TableMetaData,select()), declarative ORM mapping (DeclarativeBase+Mapped[]), the ORM's CRUD arc (insert, query, update, delete), the Active Record vs. Data Mapper contrast (peewee vs. SQLAlchemy), one-to-many and many-to-many relationships, the identity map, and the session lifecycle.
- Intermediate (Examples 29-56) -- the four session object states and
expire/refresh, the unit of work's flush ordering, dirty tracking, and autoflush, lazy loading and the
DetachedInstanceError, the N+1 problem reproduced and measured, the three eager-loading strategies (selectinload,joinedload,subqueryload) contrasted,raiseload()as a guard, ORM transactions and nested savepoints, connection pooling (sizing, exhaustion,pool_pre_ping), the full Alembic workflow (init, hand-written migrations, upgrade/downgrade, autogenerate and its blind spots, reversible vs. explicitly-irreversible data migrations), and ORM cascade vs. databaseON DELETE CASCADE. - Advanced (Examples 57-78) -- 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.gatherover 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.
The 27 concepts this topic covers
- co-01 · data-access-spectrum -- the raw-SQL to query-builder to ORM spectrum, and what each tier buys and hides.
- co-02 · raw-sql-dbapi -- the PEP 249 DB-API: connection, cursor, execute, fetch, and manual row-to-object mapping.
- co-03 · query-builder-core -- building queries as composable data structures rather than concatenated strings.
- co-04 · query-builder-library-contrast -- how a standalone builder (PyPika) and SQLAlchemy Core differ in style and scope.
- co-05 · parameterized-queries-and-emitted-sql -- placeholders keep values out of the SQL text; inspecting the emitted SQL and its bound parameters.
- co-06 · declarative-orm-mapping -- SQLAlchemy 2.0's
DeclarativeBase+Mapped[...]typed column mapping. - co-07 · active-record-vs-data-mapper -- the object persists itself (peewee) vs. a session/mapper persists it (SQLAlchemy).
- co-08 · relationship-mapping -- one-to-many/foreign-key relationships and bidirectional
back_populates. - co-09 · many-to-many-association -- association tables and association objects for M:N links.
- co-10 · identity-map -- one Python object per primary key within a session, deduplicated across queries.
- co-11 · session-object-states -- transient to pending to persistent to detached, plus expire/refresh.
- co-12 · unit-of-work -- the session batches, orders, and flushes changes as one coordinated write.
- co-13 · lazy-loading -- relationships fetched on first access, and the
DetachedInstanceErrorwhen the session is gone. - co-14 · eager-loading-strategies --
selectinload,joinedload, andsubqueryload, and how each shapes the emitted SQL. - co-15 · n-plus-1-problem -- one query fanning out into many, diagnosed by query count and fixed by eager loading.
- co-16 · raiseload-guard --
raiseload()turns an accidental lazy load into a loud error instead of a silent query. - co-17 · orm-transactions -- session commit/rollback,
begin()scope, and nested savepoints. - co-18 · connection-pooling --
pool_size/max_overflow, pool exhaustion, andpool_pre_pingrecovery. - co-19 · migrations-alembic-workflow -- versioned schema evolution with Alembic (
init, revision,upgrade). - co-20 · autogenerate-migrations -- deriving a migration from a model-to-schema diff, and why you still review it.
- co-21 · migration-reversibility -- writing real
downgradepaths and the expand-contract zero-downtime pattern. - co-22 · cascade-delete-orm -- ORM
cascade="all, delete-orphan"vs. databaseON DELETE CASCADE. - co-23 · bulk-operations -- set-oriented bulk insert/update instead of per-object round trips.
- co-24 · async-orm-session --
create_async_engine+AsyncSession, and why lazy loading is forbidden there. - co-25 · orm-vs-raw-sql-tradeoff -- where the ORM's leverage helps (CRUD) and where raw SQL wins (reporting, bulk).
- co-26 · query-builder-vs-orm-tradeoff -- composition and injection safety without the identity-map/change-tracking machinery.
- co-27 · choosing-tier-per-workload -- matching CRUD, analytics, and hot-path workloads to the right tier.
Examples by Level
Beginner (Examples 1–28)
- Example 1: Spectrum: Same Query, Three Ways
- Example 2: DB-API Connect And Cursor
- Example 3: DB-API Parameterized Query
- Example 4: DB-API Row To Typed Dataclass
- Example 5: DB-API Executemany Batch Insert
- Example 6: DB-API Transaction Commit And Rollback
- Example 7: Query Builder Select
- Example 8: Query Builder Where Compose
- Example 9: Query Builder Join
- Example 10: Query Builder Execute
- Example 11: Query Builder vs String Safety
- Example 12: SQLAlchemy Core Table
- Example 13: SQLAlchemy Core Select
- Example 14: Declarative Model Basic
- Example 15: Declarative Typed Columns
- Example 16: ORM Insert Object
- Example 17: ORM Query Select
- Example 18: ORM Update Object
- Example 19: ORM Delete Object
- Example 20: Active Record Peewee Model
- Example 21: Active Record vs Data Mapper
- Example 22: Relationship One To Many
- Example 23: Relationship Back Populates
- Example 24: Foreign Key Mapping
- Example 25: Many To Many Association Table
- Example 26: Many To Many Navigate
- Example 27: Identity Map Same Object
- Example 28: Session Lifecycle Begin
Intermediate (Examples 29–56)
- Example 29: Session States Transient Pending
- Example 30: Session Expire Refresh
- Example 31: Unit Of Work Flush Order
- Example 32: Unit Of Work Dirty Tracking
- Example 33: Unit Of Work Autoflush
- Example 34: Lazy Loading Default
- Example 35: Lazy Loading Detached Error
- Example 36: N Plus 1 Reproduce
- Example 37: Eager Selectinload
- Example 38: Eager Joinedload
- Example 39: Eager Subqueryload
- Example 40: Eager Strategy Contrast
- Example 41: Raiseload Guard
- Example 42: N Plus 1 Count Assert
- Example 43: ORM Transaction Commit Rollback
- Example 44: ORM Nested Savepoint
- Example 45: Connection Pool Basics
- Example 46: Pool Exhaustion
- Example 47: Pool Pre Ping
- Example 48: Alembic Init
- Example 49: Alembic First Migration
- Example 50: Alembic Upgrade Downgrade
- Example 51: Alembic Autogenerate
- Example 52: Alembic Autogen Review
- Example 53: Migration Reversible Data
- Example 54: Migration Irreversible Guard
- Example 55: Cascade Delete ORM
- Example 56: Cascade vs DB Foreign Key
Advanced (Examples 57–78)
- Example 57: Bulk Insert ORM
- Example 58: Bulk Update Core
- Example 59: Bulk vs ORM Performance
- Example 60: Async Engine Session
- Example 61: Async Eager Loading
- Example 62: Async Lazy Forbidden
- Example 63: Async Concurrent Sessions
- Example 64: ORM vs Raw CRUD
- Example 65: ORM vs Raw Reporting
- Example 66: Query Builder vs ORM Dynamic Filter
- Example 67: Query Builder vs ORM Tradeoff
- Example 68: Choosing Tier CRUD
- Example 69: Choosing Tier Analytics
- Example 70: Choosing Tier Hot Path
- Example 71: Hybrid ORM Plus Raw
- Example 72: Identity Map Across Queries
- Example 73: Relationship Lazy Strategy Config
- Example 74: Self Referential Relationship
- Example 75: Association Object M2M
- Example 76: Migration Zero Downtime
- Example 77: Connection Pool Tuning
- Example 78: Capstone Preview Three Tier
← Previous: 26 · Advanced SQL & Query Performance Drilling · Next: Beginner Examples →
Last updated July 17, 2026