sqlalchemy-patterns · diff
git:20260629.89b93c6 to git:20261006.98751eb
137 added, 840 removed. Audit A to A.
---
name: sqlalchemy-patterns
description: >-
Comprehensive SQLAlchemy 2.0+ async + PostgreSQL patterns — declarative
models, relationships, type-safe Mapped[ columns, querying, eager loading,
transactions, and Alembic migrations. Use when working with SQLAlchemy; to
write a model, declarative model, or ORM model; add a column, mapped_column,
or Mapped[ annotation; define a relationship, foreign key, or index on a
table; choose selectinload, joinedload, or load_only; configure an async
session or call session.execute; fix N+1 queries; do a bulk insert or upsert;
use JSONB or a PostgreSQL type; count rows; write a migration model; or write
any SQLAlchemy query. Not for SQLAlchemy 1.x, non-async (sync) codebases, or
non-PostgreSQL databases (MySQL, SQLite, etc.).
---
# SQLAlchemy 2.0+ Patterns (Async · PostgreSQL)
- The definitive reference for writing SQLAlchemy 2.0+ code. PostgreSQL is the target database. **Async is the default execution model.** Every pattern here is 2.0-style — zero legacy `Column()` / `session.query()` / `declarative_base()` patterns. When you write any model, query, or migration, follow these rules exactly.
+ Rules for writing SQLAlchemy 2.0 / 2.1 code where **PostgreSQL is the database and async is the execution model**. Everything here is 2.0-style — no legacy `Column()` class attributes, `session.query()`, or `declarative_base()`.
- > This is a **global, project-agnostic** skill. Examples use generic names (`User`, `Order`). Adapt names to the project, never copy project-specific session names or pool numbers from examples as if they were rules.
+ > Project-agnostic: examples use generic names (`User`, `Order`). Adapt names to the project; never copy example session names or pool numbers as if they were rules.
+ **Versions:** works on SQLAlchemy 2.0 and 2.1 (current release line). On 2.1: Python ≥ 3.11, install `sqlalchemy[asyncio]` (greenlet is no longer pulled in by default), and always name the driver in the URL (`postgresql+asyncpg://` / `postgresql+psycopg://`). Upgrade notes: `references/migration-2.1.md`.
+
---
- ## Quick Reference Cheatsheet (scan in 30 seconds)
+ ## Quick Reference Cheatsheet
| Rule | Do this |
|---|---|
- | Base class | `class Base(DeclarativeBase)` — never `declarative_base()` |
- | Columns | `mapped_column()` + `Mapped[T]` — never bare `Column()` |
+ | Base class | `class Base(AsyncAttrs, DeclarativeBase)` — never `declarative_base()` |
+ | Columns | `mapped_column()` + `Mapped[T]` — never bare `Column()` on a mapped class |
| Nullable | `Mapped[str]` = NOT NULL · `Mapped[str \| None]` = NULL |
- | PK (uuid) | `Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid4)` |
- | Created/updated | `server_default=func.now()`; updated adds `onupdate=...` |
- | Relationship | `Mapped[list["X"]]` (collection) / `Mapped["X"]` (scalar) + `back_populates` — never `backref` |
- | FK | always on the **many** side; `ForeignKey("t.id", ondelete="CASCADE")` |
- | Async sessionmaker | `async_sessionmaker(engine, expire_on_commit=False)` — `expire_on_commit=False` is required |
- | Query | `select(Model)` + `await session.execute(...)` — never `session.query()` in async |
- | Get list of entities | `(await session.scalars(stmt)).all()` |
- | Get one or none | `(await session.execute(stmt)).scalar_one_or_none()` |
- | Relationship access | declare `selectinload` (collections) / `joinedload` (scalars) — lazy load = `MissingGreenlet` in async |
- | `joinedload` collection | add `.unique()` to the result — mandatory |
- | Eager load + filter | `selectinload(User.roles.and_(Role.active))` — **NOT** `.where()` |
- | List endpoint | always add `load_only(...)` with only the columns the response needs |
- | N+1 | never query inside a loop — `WHERE id IN (...)` once |
- | Bulk insert | `await session.execute(insert(Model), [{...}, ...])` — never `add()` in a loop |
- | Count | `await session.scalar(select(func.count()).select_from(Model))` — never `len(.all())` |
+ | UUID PK | `Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid4)` (maps to `Uuid` automatically) |
+ | Timestamps | `type_annotation_map = {datetime.datetime: DateTime(timezone=True)}` on Base; `server_default=func.now()` |
+ | `updated` column in async | `onupdate=func.now(), server_onupdate=FetchedValue()` + `__mapper_args__ = {"eager_defaults": True}` |
+ | Relationship | `Mapped[list["X"]]` / `Mapped["X"]` + `back_populates` — never `backref` |
+ | Cascade in async | `"save-update, merge, expunge, delete, delete-orphan"` — not `"all"` |
+ | Huge collection | `WriteOnlyMapped["X"]` — never `DynamicMapped` (not async-compatible) |
+ | FK | on the **many** side; `ForeignKey("t.id", ondelete="CASCADE")` |
+ | Sessionmaker | `async_sessionmaker(engine, expire_on_commit=False)` |
+ | Query | `select(Model)` + `await session.execute / scalars / scalar / get` |
+ | List of entities | `(await session.scalars(stmt)).all()` |
+ | One or none | `(await session.execute(stmt)).scalar_one_or_none()` |
+ | Relationship access | `selectinload` (collections) / `joinedload` (scalars) on the query |
+ | `joinedload` on a collection | add `.unique()` to the result |
+ | Eager load + filter | `selectinload(User.roles.and_(Role.active))` — not `.where()` |
+ | List endpoint | `load_only(...)` with only the columns the response needs |
+ | N+1 | never query inside a loop — one `WHERE id IN (...)` |
+ | Bulk insert | `await session.execute(insert(Model), [{...}, ...])`; `.returning(Model)` via `session.scalars` |
+ | Count | `await session.scalar(select(func.count()).select_from(Model))` |
| NULL test | `col.is_(None)` / `col.is_not(None)` — never `== None` |
| Upsert | `pg_insert(Model)...on_conflict_do_update(index_elements=[...], set_={...})` |
| JSON column | `Mapped[dict] = mapped_column(JSONB, default=dict)` — JSONB, never JSON |
- | Commit lives in | the service/unit-of-work layer — never in a repository helper |
+ | Commit lives in | the service / unit-of-work layer — never a repository helper |
+ | Concurrency | one `AsyncSession` per task — never shared across `asyncio.gather` |
---
## Pre-Query Checklist (run before writing ANY DB function)
- 1. **Read-only?** Use the read/replica session if the project exposes one; otherwise the standard session.
- 2. **Returns a list?** Add `load_only(...)` selecting only the columns the response schema needs.
- 3. **Touches a relationship?** Add an explicit loader on the outer query — `selectinload` for collections, `joinedload` for scalars. Never lazy-load in async.
- 4. **Any query inside a loop?** Replace with one `WHERE col.in_([...])`. Never call `session.get()` / `session.execute()` per iteration.
- 5. **Counting?** Use `select(func.count())`. Never `len((await session.scalars(...)).all())`.
- 6. **Async?** `select()` + `await session.execute/scalars`. Never `session.query()`. Confirm `expire_on_commit=False`.
- 7. **Filtering on NULL?** `is_()` / `is_not()`. Never `== None`.
- 8. **Bulk write?** `add_all()` or Core `insert/update/delete`. Never `add()` in a loop.
- 9. **Insert that may collide?** PostgreSQL `insert` + `on_conflict_do_update`.
- 10. **Case-insensitive match?** `ilike()` or `func.lower(col) == value.lower()`.
- 11. **Where does `commit()` live?** Service layer. Repository functions read/stage only.
+ 1. **Read-only?** Use the read/replica session if the project exposes one.
+ 2. **Returns a list?** Add `load_only(...)` with only the columns the response schema needs.
+ 3. **Touches a relationship?** Add an explicit loader on the outer query — `selectinload` for collections, `joinedload` for scalars. Lazy loading in async raises `MissingGreenlet`.
+ 4. **Any query inside a loop?** Replace with one `WHERE col.in_([...])`.
+ 5. **Counting?** `select(func.count())`, never `len(... .all())`.
+ 6. **Async correctness?** `select()` + `await session.*`; `expire_on_commit=False`; no attribute access that could trigger IO after commit or after an UPDATE flush.
+ 7. **Filtering on NULL?** `is_()` / `is_not()`.
+ 8. **Bulk write?** `add_all()` or Core `insert/update/delete` with a list of dicts — never `add()` in a loop.
+ 9. **Insert that may collide?** PostgreSQL `insert` + `on_conflict_do_update` / `do_nothing`.
+ 10. **Case-insensitive match?** `ilike()` or `func.lower(col) == value.lower()` (with a matching functional index).
+ 11. **Where does `commit()` live?** Service layer. Repository functions read / stage / flush only.
---
- ## Section 1 — Declarative Models (2.0 style)
+ ## Decision Guide
- **Rule: subclass `DeclarativeBase`.** `declarative_base()` is the legacy 1.x factory; the class form gives full PEP 484 typing with no plugins.
+ **Which loader?**
- ```python
- # WRONG — legacy
- from sqlalchemy.orm import declarative_base
- Base = declarative_base()
- class User(Base):
- id = Column(Integer, primary_key=True) # untyped, no Mapped[]
+ | You will access… | Use |
+ |---|---|
+ | a collection (one-to-many, many-to-many) | `selectinload(Parent.children)` |
+ | a scalar (many-to-one, one-to-one) | `joinedload(Child.parent)` (`innerjoin=True` if FK NOT NULL) |
+ | a collection, filtered on the related table in WHERE | explicit `.join()` + `contains_eager()` |
+ | a subset of a collection | `selectinload(Parent.children.and_(...))` |
+ | a huge collection | `WriteOnlyMapped` + `parent.children.select()` |
+ | one attribute, once, no loader planned | `await obj.awaitable_attrs.rel` or `await session.refresh(obj, ["rel"])` |
+ | nothing beyond what you loaded (tests) | add `raiseload("*")` to fail loudly |
- # CORRECT — 2.0
+ **Which write path?**
+
+ | Situation | Use |
+ |---|---|
+ | A few objects; need events, relationships, cascades | `session.add()` / `add_all()` + flush |
+ | Many rows from dicts | `session.execute(insert(Model), rows)` |
+ | Many rows and you need them back | `session.scalars(insert(Model).returning(Model), rows)` |
+ | Rows may already exist | `pg_insert(...).on_conflict_do_update / do_nothing` |
+ | Update many rows, same values | `update(Model).where(...).values(...)` |
+ | Update many rows, different values per PK | `session.execute(update(Model), [{"id": ..., ...}, ...])` |
+
+ **Which transaction style?** Service function owns the boundary: `async with async_session.begin() as session:` (commit on success, rollback on exception). Repository helpers take a `session`, stage work, and `flush()` if they need generated PKs. Use `begin_nested()` for a savepoint that may fail independently.
+
+ ---
+
+ ## Canonical Setup
+
+ ```python
+ import datetime
import uuid
from uuid import uuid4
- from datetime import datetime
- from sqlalchemy import MetaData, Uuid, func, text
- from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
- # Constraint naming convention — so Alembic generates stable, predictable names.
+ from sqlalchemy import DateTime, ForeignKey, MetaData, func, select
+ from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload
+
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
- class Base(DeclarativeBase):
+ class Base(AsyncAttrs, DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
- ```
- *Why the naming convention: unnamed constraints get random DB-assigned names; autogenerated migrations then can't reliably drop/alter them.*
-
- **Rule: every column is `mapped_column()` + `Mapped[T]`.** `mapped_column()` reads the annotation for type and nullability. Bare `Column()` carries no ORM typing.
-
- **Nullability is inferred from the annotation — do not also pass `nullable=`:**
+ type_annotation_map = {datetime.datetime: DateTime(timezone=True)}
- ```python
class User(Base):
__tablename__ = "user"
- id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid4)
- email: Mapped[str] # NOT NULL
- full_name: Mapped[str | None] # NULL
- is_active: Mapped[bool] = mapped_column(server_default=text("true"))
- ```
- *Why: `Mapped[str]` → NOT NULL, `Mapped[str | None]` → NULL. The annotation is the single source of truth.*
-
- **Server defaults vs Python defaults — `default=` runs in Python at INSERT; `server_default=` is emitted as DDL and runs in the database.**
-
- ```python
- # WRONG — created_at set by the app clock, drifts between app servers, naive datetime
- created: Mapped[datetime] = mapped_column(default=datetime.utcnow)
-
- # CORRECT — database clock, single source of truth
- created: Mapped[datetime] = mapped_column(server_default=func.now())
- updated: Mapped[datetime] = mapped_column(
- server_default=func.now(),
- onupdate=func.now(), # recomputed on every UPDATE flush
- )
- ```
- *Why: `func.now()` uses the DB clock — consistent across processes and timezone-correct (`TIMESTAMP`). `onupdate=` fires on UPDATE only.*
-
- **Integer PK:** `id: Mapped[int] = mapped_column(primary_key=True)` (autoincrement is implicit). **UUID PK:** use the core `Uuid` type (portable; emits native `UUID` on PostgreSQL) with `default=uuid4`.
-
- **Abstract base for shared columns** — use a mixin / `__abstract__` base, not copy-paste:
-
- ```python
- class TimestampMixin:
- created: Mapped[datetime] = mapped_column(server_default=func.now())
- updated: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
-
- class User(TimestampMixin, Base):
- __tablename__ = "user"
- id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid4)
- ```
-
- **`__table_args__`** — tuple for constraints/indexes; dict for options; tuple-with-trailing-dict for both:
- ```python
- __table_args__ = (
- UniqueConstraint("email", name="uq_user_email"),
- Index("ix_user_active", "is_active"),
- {"comment": "Application users"}, # dict MUST be last
- )
- ```
-
- ---
-
- ## Section 2 — Relationships
-
- **Rule: type the relationship with `Mapped[...]` and pair both sides with `back_populates`. Never use `backref`** (it hides the reverse side from type checkers and configures it implicitly).
-
- ```python
- # WRONG — backref (untyped reverse side) and FK on the one side
- class Parent(Base):
- __tablename__ = "parent"
- id: Mapped[int] = mapped_column(primary_key=True)
- child_id: Mapped[int] = mapped_column(ForeignKey("child.id")) # FK on the wrong side
- children = relationship("Child", backref="parent") # backref, no Mapped[]
-
- # CORRECT — one-to-many. FK lives on the MANY (child) side.
- class Parent(Base):
- __tablename__ = "parent"
- id: Mapped[int] = mapped_column(primary_key=True)
- children: Mapped[list["Child"]] = relationship(back_populates="parent") # collection
-
- class Child(Base):
- __tablename__ = "child"
- id: Mapped[int] = mapped_column(primary_key=True)
- parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id", ondelete="CASCADE"))
- parent: Mapped["Parent"] = relationship(back_populates="children") # scalar
- ```
- *Why FK on the many side: a child has one parent; the parent's "many children" is derived from the children's FK.*
-
- **`cascade="all, delete-orphan"`** — deletes children when the parent is deleted AND when a child is removed from the collection. Pair it with a DB-side `ondelete="CASCADE"` + `passive_deletes=True` so the database does the bulk delete:
- ```python
- children: Mapped[list["Child"]] = relationship(
- back_populates="parent", cascade="all, delete-orphan", passive_deletes=True
- )
- ```
- *Why `passive_deletes=True`: without it, the ORM SELECTs every child then DELETEs them one by one. With it, the DB `ON DELETE CASCADE` handles them in one statement.*
-
- **Many-to-many (no extra columns) — `secondary=` with a `Table`:**
- ```python
- user_role = Table(
- "user_role", Base.metadata,
- Column("user_id", ForeignKey("user.id", ondelete="CASCADE"), primary_key=True),
- Column("role_id", ForeignKey("role.id", ondelete="CASCADE"), primary_key=True),
- )
- class User(Base):
- roles: Mapped[list["Role"]] = relationship(secondary=user_role, back_populates="users")
- ```
-
- **Many-to-many WITH extra columns — association object** (map the join table as a class):
- ```python
- class UserRole(Base): # the association row
- __tablename__ = "user_role"
- user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True)
- role_id: Mapped[int] = mapped_column(ForeignKey("role.id"), primary_key=True)
- granted_at: Mapped[datetime] = mapped_column(server_default=func.now())
- user: Mapped["User"] = relationship(back_populates="role_links")
- role: Mapped["Role"] = relationship(back_populates="user_links")
- ```
- *Why: `secondary=` can't store columns on the join. The moment the link needs its own data, use an association object.*
-
- **`viewonly=True`** — read-only relationship (computed join, never flushed). Use for derived collections; mutations to it are silently ignored, so never write through a `viewonly` relationship.
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid4)
+ email: Mapped[str] = mapped_column(unique=True)
+ full_name: Mapped[str | None]
+ created: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
+ orders: Mapped[list["Order"]] = relationship(back_populates="user")
- **Self-referential (trees)** — set `remote_side` to the PK:
- ```python
- class Node(Base):
- __tablename__ = "node"
+ class Order(Base):
+ __tablename__ = "order"
id: Mapped[int] = mapped_column(primary_key=True)
- parent_id: Mapped[int | None] = mapped_column(ForeignKey("node.id"))
- children: Mapped[list["Node"]] = relationship(back_populates="parent")
- parent: Mapped["Node | None"] = relationship(back_populates="children", remote_side=[id])
- ```
-
- **Non-FK join — `primaryjoin`** with `foreign()`/`remote()` markers when there is no real ForeignKey (e.g. IP-range containment). Always `viewonly=True` for these.
-
- **Catch accidental lazy loads — set `lazy="raise_on_sql"` on relationships you always eager-load.** It raises only when a load would emit SQL, surfacing missing loader options in tests before they hit production as `MissingGreenlet`.
-
- ---
-
- ## Section 3 — Type Safety with `Mapped[]`
-
- **Rule: every column is typed; mypy/pyright understand `Mapped[T]` natively in 2.0 — no stubs.**
-
- ```python
- # WRONG — untyped column (no Mapped[T]) and a shared mutable default
- class Account(Base):
- id = mapped_column(Integer, primary_key=True) # no Mapped[] → no type checking
- meta: Mapped[dict] = mapped_column(JSONB, default={}) # {} shared across ALL rows
-
- # CORRECT
- import enum
- import uuid
- from sqlalchemy import Enum, String, Uuid
- from sqlalchemy.dialects.postgresql import ARRAY, JSONB
-
- class Status(enum.Enum):
- ACTIVE = "active"
- BANNED = "banned"
-
- class Account(Base):
- __tablename__ = "account"
- id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
- status: Mapped[Status] = mapped_column(Enum(Status, name="account_status"))
- tags: Mapped[list[str]] = mapped_column(ARRAY(String))
- meta: Mapped[dict] = mapped_column(JSONB, default=dict) # default=dict → fresh {} per row
- ```
- *Why `default=dict` (not `default={}`): a literal `{}` is shared across all instances — a classic mutable-default bug.*
-
- **`type_annotation_map` — map a Python type to a SQL type once, project-wide:**
- ```python
- from typing import Annotated
- from sqlalchemy.orm import registry
-
- str_255 = Annotated[str, 255]
-
- class Base(DeclarativeBase):
- registry = registry(type_annotation_map={str_255: String(255), dict: JSONB})
-
- class User(Base):
- name: Mapped[str_255] # → String(255)
- meta: Mapped[dict] # → JSONB
- ```
-
- **Custom domain type — `TypeDecorator` (always set `cache_ok`):**
- ```python
- from sqlalchemy import types
-
- class LowerString(types.TypeDecorator):
- impl = types.String
- cache_ok = True # REQUIRED — unset disables statement caching and warns
- def process_bind_param(self, value, dialect):
- return value.lower() if value is not None else value
- ```
- *Why `cache_ok = True`: SQLAlchemy 2.0 caches compiled statements keyed by type. An unset `cache_ok` emits a warning and silently disables caching for every statement using the type — a real perf regression.*
-
- **`column_property()` for a read-only computed column** — see Section 14.
-
- ---
-
- ## Section 4 — Async Session Lifecycle
-
- **Rule: `async_sessionmaker(..., expire_on_commit=False)`.** This is non-negotiable in async.
-
- ```python
- # WRONG — default expire_on_commit=True
- async_session = async_sessionmaker(engine)
- async with async_session() as s:
- user = await s.get(User, uid)
- await s.commit()
- return user.email # 💥 MissingGreenlet — attribute expired, reload needs IO
-
- # CORRECT
- from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
-
- engine = create_async_engine(
- "postgresql+asyncpg://user:pw@host/db",
- pool_pre_ping=True, # detect dead connections before use
- echo=False, # never True in production — logs every statement
- )
- async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
- ```
- *Why `expire_on_commit=False`: after commit, the default expires all attributes; the next attribute access triggers a synchronous reload, which has no greenlet in async → `MissingGreenlet`. Disabling it keeps loaded data usable after commit.*
-
- **Session scope — always a context manager. Two transaction styles:**
- ```python
- # Auto-commit on success, auto-rollback on exception:
- async with async_session() as session, session.begin():
- session.add(obj)
- # (no explicit commit — begin() commits at block exit)
-
- # Manual control:
- async with async_session() as session:
- session.add(obj)
- await session.commit()
- ```
- *Why `async with`: guarantees the connection returns to the pool even on exception. Manual `close()` leaks on error paths.*
-
- **Read replica — two engines, two sessionmakers.** Route read-only handlers to the replica sessionmaker, writes to the primary. (The exact dependency/session names are project-specific — use whatever the project exposes.)
-
- **`session.get(Model, pk)`** checks the identity map first and emits a SELECT only on a miss — the right tool for a single PK lookup. **Never `session.query()` in async** — it's the legacy sync API and unsupported on `AsyncSession`.
-
- **Need one lazy attribute without an upfront loader?** Use `AsyncAttrs` (2.0.13+): `class Base(AsyncAttrs, DeclarativeBase): ...` then `await obj.awaitable_attrs.children`. Prefer eager loaders for anything in a hot path.
-
- **`AsyncSession` is NOT concurrency-safe** — never share one session across `asyncio.gather` tasks. One session per task.
-
- **`run_sync()`** — only for sync-only operations like DDL: `await conn.run_sync(Base.metadata.create_all)`.
-
- ---
-
- ## Section 5 — Querying: Complete SELECT Guide
-
- ```python
- from sqlalchemy import select, and_, or_, not_, nulls_last
-
- # Whole entities → list[Model]
- stmt = select(User).where(User.is_active.is_(True)).order_by(User.created.desc())
- users = (await session.scalars(stmt)).all()
-
- # Specific columns → list[Row]
- stmt = select(User.id, User.email)
- rows = (await session.execute(stmt)).all() # each row: row.id, row.email
- ```
-
- **WHERE composition:** multiple `.where(a, b)` args or `and_(a, b)` = AND; `or_(...)`; `not_(...)` / `~`.
-
- **Ordering:** `User.col.desc()`, `User.col.asc()`, `nulls_last(User.col)`. **Pagination:** `.limit(n).offset(m)`. **Distinct:** `.distinct()`, or PostgreSQL `DISTINCT ON`: `select(User).distinct(User.email)`.
-
- **Terminal methods — picking wrong returns the wrong shape:**
-
- | Call | Returns |
- |---|---|
- | `(await session.scalars(stmt)).all()` | `list[Model]` (ORM objects) |
- | `await session.scalar(stmt)` | first column of first row, or `None` |
- | `(await session.execute(stmt)).scalar_one()` | exactly one value; raises if 0 or >1 |
- | `(await session.execute(stmt)).scalar_one_or_none()` | one or `None`; raises if >1 |
- | `(await session.execute(stmt)).first()` | first `Row` or `None` |
- | `(await session.execute(stmt)).all()` | `list[Row]` (tuples) |
-
- *Why `scalar_one_or_none()` for "fetch by unique key": it asserts uniqueness — a duplicate raises instead of silently returning the first.*
-
- **Filtering operators:**
- ```python
- # WRONG — Python truthiness / == on NULL, and filter_by for a complex predicate
- select(User).where(User.deleted_at == None) # linters flag this; fragile
- select(User).filter_by(age > 18) # filter_by takes kwargs only → TypeError
-
- # CORRECT
- select(User).where(User.deleted_at.is_(None)) # emits IS NULL
- select(User).where(User.age > 18) # use .where() for anything but simple equality
-
- User.id.in_(ids) # IN
- User.id.not_in(ids) # NOT IN
- User.email.ilike("%@gmail.com") # case-insensitive LIKE
- User.age.between(18, 65)
- User.deleted_at.is_(None) # IS NULL — NEVER == None
- User.deleted_at.is_not(None) # IS NOT NULL
- func.lower(User.email) == email.lower() # case-insensitive equality
-
- from sqlalchemy import exists
- stmt = select(exists().where(User.email == email)) # → await session.scalar(stmt) → bool
- ```
- *Why `is_(None)` not `== None`: `== None` works by luck via operator overloading but linters flag it and it breaks on some expressions. `is_()` always emits `IS NULL`.*
-
- ---
-
- ## Section 6 — Relationship Loading (Mandatory in Async)
-
- **This is the most important section. In async, accessing an unloaded relationship raises `MissingGreenlet` and kills the request.** Every relationship you touch must have an explicit loader on the outer query.
-
- ```python
- from sqlalchemy.orm import selectinload, joinedload, raiseload, contains_eager
-
- # WRONG — lazy load in async
- users = (await session.scalars(select(User))).all()
- for u in users:
- print(u.roles) # 💥 MissingGreenlet
-
- # CORRECT — collection → selectinload (second IN query)
- stmt = select(User).options(selectinload(User.roles))
- users = (await session.scalars(stmt)).all()
-
- # CORRECT — scalar (many-to-one) → joinedload (LEFT OUTER JOIN, same query)
- stmt = select(Order).options(joinedload(Order.customer))
- orders = (await session.scalars(stmt)).all()
- ```
-
- **Strategy choice:**
- - **`selectinload(Model.rel)`** — collections (one-to-many, many-to-many). Emits one extra `WHERE IN` query. *Why: avoids the row-multiplication a JOIN causes on collections.*
- - **`joinedload(Model.rel)`** — scalars (many-to-one, one-to-one). Adds a `LEFT OUTER JOIN`. Use `joinedload(Order.customer, innerjoin=True)` when the FK is NOT NULL.
- - **`subqueryload` / `lazyload`** — never in async.
- - **`raiseload("*")`** — add in development/tests to turn any accidental lazy load into an immediate error.
-
- **`joinedload` on a collection requires `.unique()`** on the result — otherwise the JOIN's duplicate rows produce duplicate parents (and 2.0 raises):
- ```python
- stmt = select(User).options(joinedload(User.roles)) # collection via JOIN
- users = (await session.scalars(stmt)).unique().all() # .unique() MANDATORY
- ```
- (Prefer `selectinload` for collections — no `.unique()` needed.)
-
- **Nesting:**
- ```python
- select(User).options(selectinload(User.roles).selectinload(Role.permissions))
- ```
-
- **Eager load WITH a filter — use `relationship.and_()`, NOT `.where()`:**
- ```python
- # WRONG — Load options have no .where(); this is invalid syntax
- select(User).options(selectinload(User.roles).where(Role.is_active == True))
-
- # CORRECT — put the criteria inside the relationship attribute
- select(User).options(selectinload(User.roles.and_(Role.is_active == True)))
- ```
- *Why: the criteria attaches to the relationship's join condition. `.and_()` works identically across `selectinload`/`joinedload`/`lazyload`.*
-
- **Global per-entity criteria — `with_loader_criteria()`** (applies everywhere that entity loads, including nested loads — ideal for soft-delete):
- ```python
- from sqlalchemy.orm import with_loader_criteria
- select(User).options(
- selectinload(User.roles),
- with_loader_criteria(Role, lambda cls: cls.deleted_at.is_(None)),
- )
- ```
-
- **`contains_eager(Model.rel)`** — when you've already written an explicit `.join()` and want it to populate the relationship (e.g. to filter on the joined table):
- ```python
- stmt = (select(User).join(User.roles)
- .where(Role.name == "admin")
- .options(contains_eager(User.roles)))
- ```
-
- ---
-
- ## Section 7 — Partial Loading: `load_only` and `defer`
-
- **Rule: every list endpoint uses `load_only()` with only the columns the response schema returns.** Large tables have heavy columns (blobs, JSONB, text) you don't need in a list view.
-
- ```python
- from sqlalchemy.orm import load_only, defer, undefer
-
- # WRONG — list endpoint pulls every column, including heavy text/JSONB/blob columns
- stmt = select(User)
-
- # CORRECT — list view fetches only what the response needs
- stmt = select(User).options(load_only(User.id, User.email, User.is_active, User.created))
- ```
- *Why: `SELECT *` on a wide table moves megabytes per page you immediately discard.*
-
- **`defer(col)`** — exclude one heavy column, load it lazily only if accessed. **`undefer(col)`** — force-load a column deferred at the mapping level.
-
- **Combine column + relationship loaders in one `.options()`:**
- ```python
- stmt = select(User).options(
- load_only(User.id, User.email),
- selectinload(User.roles).load_only(Role.id, Role.name),
- )
- ```
-
- **Raise instead of lazy-loading a column** — `load_only(..., raiseload=True)` (2.0) whitelists columns and raises on access to any other; `defer(col, raiseload=True)` (1.4) raises on access to that one column. Use to catch unintended column loads in tests.
-
- ---
-
- ## Section 8 — N+1 Elimination & Batch Patterns
-
- **N+1 = one query to get N rows, then one query per row inside a loop. Never do this.**
-
- ```python
- # WRONG — N+1 (one query per id)
- for uid in user_ids:
- user = await session.get(User, uid)
-
- # CORRECT — one query for all
- users = (await session.scalars(
- select(User).where(User.id.in_(user_ids))
- )).all()
- ```
- *Why: N round-trips to the DB dominate latency; one `IN` query is a single round-trip.*
-
- **For relationship N+1 → `selectinload`** (Section 6). **For repeated single-PK access of already-loaded objects → `session.get()`** uses the identity map (no SQL).
-
- **Bulk writes — never `add()` in a loop:**
- ```python
- # WRONG
- for row in rows:
- session.add(User(**row)) # unit-of-work overhead per object
-
- # CORRECT — ORM, one flush
- session.add_all([User(**row) for row in rows])
- await session.commit()
-
- # FASTEST — Core bulk insert (list of dicts as 2nd arg, not .values())
- from sqlalchemy import insert
- await session.execute(insert(User), [{"email": e} for e in emails])
- await session.commit()
- ```
-
- **Bulk UPDATE / DELETE — one statement, not a loop:**
- ```python
- from sqlalchemy import update, delete
- await session.execute(update(User).where(User.id.in_(ids)).values(is_active=False))
- await session.execute(delete(User).where(User.id.in_(ids)))
- ```
- *Note: ORM-enabled bulk UPDATE/DELETE use `synchronize_session` (default `"auto"`) to keep in-session objects consistent — leave it at `"auto"` unless profiling says otherwise.*
-
- ---
-
- ## Section 9 — Counting & Aggregation
-
- **Rule: count in the database. Never fetch rows to count them.**
-
- ```python
- from sqlalchemy import func
-
- # WRONG — loads every row into memory just to count
- n = len((await session.scalars(select(User).where(User.is_active.is_(True)))).all())
-
- # CORRECT
- n = await session.scalar(
- select(func.count()).select_from(User).where(User.is_active.is_(True))
- )
- ```
- *Why: `len(.all())` transfers the whole result set; `COUNT(*)` returns a single integer.*
-
- **Other aggregates — same pattern:** `func.sum(col)`, `func.avg(col)`, `func.max(col)`, `func.min(col)`.
-
- **Group / having / conditional count (PostgreSQL `FILTER`):**
- ```python
- stmt = (
- select(User.team_id,
- func.count().label("total"),
- func.count().filter(User.is_active.is_(True)).label("active"))
- .group_by(User.team_id)
- .having(func.count() > 5)
- )
- rows = (await session.execute(stmt)).all()
- ```
-
- ---
-
- ## Section 10 — Subqueries, EXISTS, CTEs
-
- ```python
- from sqlalchemy import exists, select, func, union_all
-
- # WRONG — IN (subquery) materializes the entire id set just to test membership
- select(User).where(User.id.in_(select(Order.user_id).where(Order.total > 100))) # for "has any", prefer EXISTS
-
- # CORRECT — EXISTS short-circuits on the first matching row
- select(User).where(select(1).where(Order.user_id == User.id, Order.total > 100).exists())
-
- # Scalar subquery as a value
- order_count = (
- select(func.count(Order.id)).where(Order.user_id == User.id).scalar_subquery()
- )
-
- # EXISTS — prefer over IN for "has any" checks
- has_admin = await session.scalar(select(exists().where(Role.name == "admin")))
-
- # Relationship EXISTS helpers
- select(User).where(User.roles.any(Role.name == "admin")) # collection: any()
- select(Order).where(Order.customer.has(User.is_active.is_(True))) # scalar: has()
- ```
- *Why `exists()` over `IN (subquery)`: the planner short-circuits on the first match instead of materializing the full set.*
-
- **Recursive CTE (tree expansion) — always include a depth guard / `UNION ALL`:**
- ```python
- # Base term: roots
- base = select(Node.id, Node.parent_id, literal(0).label("depth")).where(Node.parent_id.is_(None))
- cte = base.cte("tree", recursive=True)
- parent = cte.alias()
- # Recursive term: children of already-collected nodes, with a depth cap
- recursive = (
- select(Node.id, Node.parent_id, (parent.c.depth + 1).label("depth"))
- .join(parent, Node.parent_id == parent.c.id)
- .where(parent.c.depth < 10) # guard against cycles / runaway recursion
- )
- cte = cte.union_all(recursive)
- rows = (await session.execute(select(cte.c.id, cte.c.depth))).all()
- ```
- *Why the depth guard: a cycle in self-referential data makes a recursive CTE loop forever.*
-
- ---
-
- ## Section 11 — Transactions
-
- **Rule: the unit of work owns the transaction. Use `session.begin()` in the service layer; repository helpers stage work and never commit.**
-
- ```python
- # WRONG — repository helper commits; caller can no longer roll back the whole unit of work
- async def create_user(session, data):
- user = User(**data)
- session.add(user)
- await session.commit() # 💥 partial commit — if assign_role later fails, user persists
-
- # CORRECT — service layer owns the transaction boundary; repos only stage work
- async with async_session() as session:
- async with session.begin(): # commit on success, rollback on exception
- await create_user(session, data)
- await assign_role(session, user_id, role_id)
- # both succeed or both roll back
- ```
- *Why: if a repository commits, a later step's failure can't roll back the earlier write — you get partial, inconsistent state.*
-
- - **`await session.flush()`** — push pending changes to the DB (assigns PKs, runs constraints) without committing. Use when you need a generated PK mid-transaction.
- - **`await session.commit()`** — flush + COMMIT. **`await session.rollback()`** — discard everything since the last commit.
- - **`await session.refresh(obj)`** — reload from DB (needed only when `expire_on_commit=True`).
- - **Savepoints — `async with session.begin_nested():`** — partial rollback inside a larger transaction (e.g. retrying one row in a batch without aborting the rest).
-
- ---
-
- ## Section 12 — PostgreSQL-Specific Patterns
-
- **Types** (import from `sqlalchemy.dialects.postgresql as pg`):
- ```python
- from sqlalchemy import JSON, Uuid
- from sqlalchemy.dialects import postgresql as pg
-
- # WRONG — plain JSON (text storage, not indexable, no containment operators)
- data: Mapped[dict] = mapped_column(JSON, default=dict)
-
- # CORRECT
- id: Mapped[uuid.UUID] = mapped_column(Uuid, default=uuid4) # portable, native UUID on PG
- data: Mapped[dict] = mapped_column(pg.JSONB, default=dict) # ALWAYS JSONB, never JSON
- tags: Mapped[list[str]] = mapped_column(pg.ARRAY(String))
- ip: Mapped[str] = mapped_column(pg.INET)
- status: Mapped[str] = mapped_column(pg.ENUM("a", "b", name="my_enum", create_type=False))
- ```
- *Why JSONB over JSON: JSONB is binary, indexable (GIN), and supports containment operators. Plain JSON is text — no indexing. Why `create_type=False`: lets Alembic manage the `CREATE TYPE` so the migration doesn't try to recreate it.*
- *`pg.UUID(as_uuid=True)` is the PG-specific equivalent of `Uuid` — use core `Uuid` unless you need PG-only behavior.*
-
- **JSONB operations** (operators verified against the PG dialect):
- ```python
- Model.data.op("->>")("key") # -> 'key'::text (text value)
- Model.data.op("->")("key") # -> 'key' (json value)
- Model.data.contains({"k": "v"}) # @> containment
- Model.data.has_key("k") # ? key exists
- Model.data.has_any(["a", "b"]) # ?| any key exists
- func.jsonb_set(Model.data, "{k}", '"v"') # update nested key
- ```
-
- **Upsert — `INSERT ... ON CONFLICT`** (must import `insert` from the PG dialect):
- ```python
- from sqlalchemy.dialects.postgresql import insert as pg_insert
-
- stmt = pg_insert(User).values([{"email": e, "name": n} for e, n in rows])
- stmt = stmt.on_conflict_do_update(
- index_elements=[User.email], # the unique key
- set_={"name": stmt.excluded.name, "updated": func.now()}, # excluded = the row that failed to insert
- )
- await session.execute(stmt)
- # Ignore-on-conflict variant:
- await session.execute(pg_insert(User).values(...).on_conflict_do_nothing(index_elements=[User.email]))
- ```
-
- **RETURNING:**
- ```python
- stmt = pg_insert(User).values(email="a@b.c").returning(User.id, User.created)
- row = (await session.execute(stmt)).one()
- ```
-
- **Window functions:**
- ```python
- func.row_number().over(partition_by=Order.user_id, order_by=Order.created.desc())
- func.rank().over(order_by=Order.total.desc())
- func.lag(Order.total).over(order_by=Order.created)
- ```
-
- **Full-text search:**
- ```python
- # Query
- stmt = select(Doc).where(
- func.to_tsvector("english", Doc.body).op("@@")(func.plainto_tsquery("english", q))
- )
- # Index (GIN on the tsvector expression)
- Index("ix_doc_fts", func.to_tsvector("english", Doc.body), postgresql_using="gin")
- ```
-
- **Common `func.*`:** `func.now()`, `func.gen_random_uuid()`, `func.coalesce(col, default)`, `func.nullif(col, "")`, `func.array_agg(col)`, `func.string_agg(col, ", ")`.
-
- ---
-
- ## Section 13 — Indexes & Constraints
-
- All in `__table_args__`:
- ```python
- from sqlalchemy import Index, UniqueConstraint, CheckConstraint, ForeignKeyConstraint, func
-
- # WRONG — plain index on email, but queries filter func.lower(email): the index is never used
- # Index("ix_user_email", "email") + WHERE func.lower(email) == x → sequential scan
- # Index("ix_user_meta_btree", "meta") → B-tree can't serve JSONB containment (@>)
-
- # CORRECT
- __table_args__ = (
- Index("ix_user_email_lower", func.lower(User.email)), # functional
- Index("ix_user_meta", "meta", postgresql_using="gin"), # GIN for JSONB/ARRAY/FTS
- Index("ix_user_active", "team_id",
- postgresql_where=text("is_active")), # partial index
- Index("ix_order_cover", "user_id", postgresql_include=["status"]), # covering index
- UniqueConstraint("email", name="uq_user_email"),
- CheckConstraint("age >= 0", name="ck_user_age_nonneg"),
- )
- ```
- *Why functional index `lower(email)`: a query filtering `func.lower(email) == x` can only use an index built on `lower(email)`. Why GIN: B-tree can't index JSONB/array containment. Why partial: a `WHERE is_active` index is smaller and faster when you only ever query active rows.*
-
- For multi-column FKs use `ForeignKeyConstraint([...], [...], ondelete="CASCADE", name="fk_...")`.
-
- ---
-
- ## Section 14 — Hybrid & Column Properties
-
- **`@hybrid_property`** — one definition that works as a Python attribute AND in SQL `WHERE`. **Use the `.inplace.expression` form (2.0.4+)** so type checkers stay happy:
- ```python
- from sqlalchemy.ext.hybrid import hybrid_property
- from sqlalchemy import ColumnElement, func, type_coerce, Float
-
- class Interval(Base):
- start: Mapped[int]
- end: Mapped[int]
-
- @hybrid_property
- def length(self) -> int: # Python side
- return self.end - self.start
-
- # WRONG — redefining `length` as the expression trips PEP 484 type checkers
- # @length.expression
- # def length(cls):
- # return cls.end - cls.start
-
- # CORRECT — .inplace lets the SQL method keep a private name (2.0.4+)
- @length.inplace.expression
- @classmethod
- def _length_expr(cls) -> ColumnElement[int]: # SQL side
- return cls.end - cls.start
- ```
- *Why `.inplace`: the old `@length.expression` redefines the same name and trips PEP 484 checkers. `.inplace` mutates the hybrid in place so the SQL method can have a private name.*
-
- **`column_property()`** — a read-only column computed from a subquery, loaded with the entity:
- ```python
- from sqlalchemy.orm import column_property
- order_count: Mapped[int] = column_property(
- select(func.count(Order.id)).where(Order.user_id == id).correlate_except(Order).scalar_subquery()
- )
- ```
-
- **`association_proxy`** — expose a nested attribute directly (e.g. `user.role_names` from `user.roles[].name`).
-
- ---
-
- ## Section 15 — Events
-
- **Events are synchronous.** Inside a handler: no `await`, no lazy loads, no ORM queries needing the greenlet. Emit SQL only through the passed `connection`.
-
- ```python
- from sqlalchemy import event
- from sqlalchemy.orm import Session
-
- # WRONG — events are synchronous; awaiting or lazy-loading inside one fails
- @event.listens_for(Model, "before_insert")
- async def bad(mapper, connection, target): # handler can't be a coroutine
- target.owner = await fetch_owner(target.owner_id) # 💥 no greenlet here
-
- # CORRECT — set local attributes only; emit SQL via the passed connection
- @event.listens_for(Model, "before_insert")
- def set_defaults(mapper, connection, target):
- target.slug = slugify(target.name)
-
- @event.listens_for(Session, "before_flush")
- def audit(session, flush_context, instances):
- for obj in session.new:
- ... # inspect pending objects
- ```
-
- **In async, attach session events to the sync session class:**
- ```python
- @event.listens_for(AsyncSession.sync_session_class, "before_flush")
- def _before_flush(session, ctx, instances):
- ...
- ```
-
- **`@event.listens_for(Engine, "connect")`** — set per-connection options (e.g. statement timeout, timezone) at connect time.
-
- ---
-
- ## Section 16 — Testing with Async SQLAlchemy
-
- **Rule: never mock the session. Use a real database (test PostgreSQL, or SQLite for pure-ORM unit tests).** Wrap each test in a transaction and roll back — no real commits, full isolation.
-
- ```python
- import pytest
- from sqlalchemy.ext.asyncio import AsyncSession
+ user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
+ user: Mapped[User] = relationship(back_populates="orders")
- # WRONG — mocking the session tests your mocks, not your SQL
- def test_create_user():
- session = MagicMock()
- session.scalar.return_value = User(id=1) # never exercises real query/constraints
+ engine = create_async_engine("postgresql+asyncpg://user:pw@host/db", pool_pre_ping=True)
+ async_session = async_sessionmaker(engine, expire_on_commit=False)
- # CORRECT — real DB, transaction per test, rolled back for isolation
- @pytest.fixture
- async def session(engine):
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- async with AsyncSession(engine, expire_on_commit=False) as s:
- yield s
- await s.rollback()
+ async def users_with_orders(limit: int) -> list[User]:
+ async with async_session() as session:
+ stmt = select(User).options(selectinload(User.orders)).order_by(User.created).limit(limit)
+ return list((await session.scalars(stmt)).all())
```
- *Why real DB: mocked sessions test your mocks, not your SQL — they miss constraint violations, type coercion, and N+1s. Why rollback per test: deterministic, isolated, no cross-test state.*
-
- **`pyproject.toml`:** `[tool.pytest.ini_options]` → `asyncio_mode = "auto"`. Build test objects with factories (e.g. `factory_boy`), not hand-written dicts repeated across tests.
+ *Why each piece: the naming convention keeps Alembic constraint names stable; the `datetime` map makes timestamps `TIMESTAMPTZ` (the default is naive `TIMESTAMP`); `expire_on_commit=False` keeps loaded attributes readable after commit; `selectinload` loads the collection inside the awaited query so nothing lazy-loads later.*
---
- ## Section 17 — Connection Pool & Engine Configuration
-
- ```python
- # WRONG — logs every statement in prod, and stale pooled connections fail requests
- engine = create_async_engine(DATABASE_URL, echo=True) # no pre_ping, no recycle
-
- # CORRECT
- engine = create_async_engine(
- DATABASE_URL,
- pool_size=10, # persistent connections kept open
- max_overflow=20, # extra connections allowed under burst load
- pool_timeout=30, # seconds to wait for a free connection before erroring
- pool_recycle=1800, # recycle connections older than 30 min (avoids server-side timeouts)
- pool_pre_ping=True, # test a connection's liveness before handing it out
- echo=False, # True only in local dev
- )
- ```
- *Why `pool_pre_ping`: a DB restart or idle timeout leaves dead connections in the pool; pre-ping replaces them transparently instead of failing the request. Why `pool_recycle`: prevents the DB from closing a connection mid-request after its idle timeout.*
+ ## Common Mistakes
- **Serverless / Lambda / external pooler (pgbouncer):** use `poolclass=NullPool` — no persistent pool; create and drop per request. *Why: a long-lived pool is useless when the runtime freezes between invocations, and double-pooling behind pgbouncer causes connection storms.*
+ | Mistake | Why it breaks | Fix |
+ |---|---|---|
+ | `async_sessionmaker(engine)` with default `expire_on_commit=True` | Attributes expire on commit; next access is implicit IO → `MissingGreenlet` | `expire_on_commit=False` |
+ | Accessing `obj.updated` after an UPDATE flush | `onupdate` values aren't fetched back; attribute is expired | `server_onupdate=FetchedValue()` + `eager_defaults=True`, or `await session.refresh(obj, ["updated"])` |
+ | `cascade="all, delete-orphan"` in async | `all` includes `refresh-expire`, which expires related objects aggressively | `"save-update, merge, expunge, delete, delete-orphan"` |
+ | `DynamicMapped` / `lazy="dynamic"` | Not compatible with asyncio | `WriteOnlyMapped` |
+ | `subqueryload` | Legacy; superseded | `selectinload` |
+ | `joinedload(collection)` without `.unique()` | ORM raises; rows are multiplied by the JOIN | `.unique()` or `selectinload` |
+ | `selectinload(X.rel).where(...)` | Loader options have no `.where()` | `selectinload(X.rel.and_(...))` |
+ | `Mapped[datetime]` assumed tz-aware | Default type map → `TIMESTAMP WITHOUT TIME ZONE` | `type_annotation_map = {datetime.datetime: DateTime(timezone=True)}` |
+ | `default={}` / `default=[]` | One mutable object shared by every row | `default=dict` / `default=list` |
+ | One `AsyncSession` shared by `asyncio.gather` tasks | Session is a single stateful transaction | One session per task |
+ | `@event.listens_for(async_engine, "connect")` | Events attach to sync objects; the `AsyncEngine` proxies a sync `Engine` | `@event.listens_for(engine.sync_engine, "connect")` |
+ | `async def` event handler | Event handlers are synchronous | Plain `def`; no awaits, no lazy loads |
+ | Bare `postgresql://` URL | Driver changes between 2.0 (psycopg2) and 2.1 (psycopg 3) | Name the driver explicitly |
+ | `session.commit()` inside a repository helper | Caller can't roll back the whole unit of work | Commit in the service layer |
+ | Mocked session in tests | Tests your mocks, not your SQL | Real DB + outer transaction + `join_transaction_mode="create_savepoint"` |
+ | `TypeDecorator` without `cache_ok = True` | Warns and disables statement caching for that type | Set `cache_ok = True` |
---
- ## Section 18 — Alembic Integration
-
- **Rule: never hand-write migration bodies — always autogenerate, then review.**
- ```bash
- # WRONG — empty hand-written revision; drifts from the models, misses constraints/types
- alembic revision -m "add is_active" # then manually typing op.add_column(...)
-
- # CORRECT — autogenerate from model metadata, then read the diff before applying
- alembic revision --autogenerate -m "add user.is_active"
- ```
-
- ```python
- # WRONG — autogenerate emits drop+add for a rename → silently destroys the column's data
- op.drop_column("user", "fullname")
- op.add_column("user", sa.Column("full_name", sa.String()))
-
- # CORRECT — rename preserves data
- op.alter_column("user", "fullname", new_column_name="full_name")
- ```
+ ## Troubleshooting
- - In `env.py`: `target_metadata = Base.metadata` so autogenerate sees your models.
- - **Always read the generated migration** — autogenerate misses column renames (it drops + recreates, losing data), some constraint changes, and server-default changes. Fix renames manually: `op.alter_column("user", "old", new_column_name="new")`.
- - **PostgreSQL ENUMs:** declare `pg.ENUM(..., create_type=False)` on the model and let the migration own `CREATE TYPE` / `DROP TYPE` via `op.execute(...)`, so re-running doesn't error on an existing type.
- - **Data migrations:** `op.execute("UPDATE ...")` in the migration body.
- - `include_schemas=True` in the context config for multi-schema databases.
- - Batch mode is for SQLite only — not needed for PostgreSQL.
+ | Symptom | Cause | Fix |
+ |---|---|---|
+ | `MissingGreenlet: greenlet_spawn has not been called` | Lazy load, deferred column, or expired attribute accessed in async | Eager loader; `expire_on_commit=False`; `FetchedValue` for `onupdate`; `awaitable_attrs` |
+ | Async extension fails because `greenlet` is missing (2.1) | Installed `sqlalchemy` without the extra | `pip install "sqlalchemy[asyncio]"` |
+ | `InvalidRequestError` about joined eager load needing unique | `joinedload` on a collection | `.unique()` or `selectinload` |
+ | `selectinload(...).where(...)` AttributeError | Loader options have no `.where()` | `.and_()` on the relationship |
+ | `SAWarning: TypeDecorator ... will not produce a cache key` | `cache_ok` unset | `cache_ok = True` |
+ | `DetachedInstanceError` | Attribute access after the session closed | Load what you need inside the session; `expire_on_commit=False` |
+ | `AmbiguousColumnError` from `filter_by` (2.1) | Name exists on more than one joined entity | `.where(Entity.col == v)` |
+ | Enum `DuplicateObject` in a migration | ENUM type created twice | `create_type=False`; migration owns `CREATE TYPE` |
+ | Errors after idle / DB restart | Stale pooled connection | `pool_pre_ping=True` + `pool_recycle` |
+ | asyncpg `prepared statement ... already exists` behind PgBouncer | Statement-name collisions | `NullPool` + `prepared_statement_name_func` (see `references/postgresql.md`) |
---
- ## Troubleshooting
+ ## References — read the one you need
- | Symptom | Cause | Fix |
- |---|---|---|
- | `MissingGreenlet: greenlet_spawn has not been called` | Lazy load / expired-attribute access in async | Add `selectinload`/`joinedload`; set `expire_on_commit=False` |
- | `InvalidRequestError: ... can't be used with ... joined eager load` (collections) | `joinedload` on a collection without dedupe | Add `.unique()` to the result, or use `selectinload` |
- | `selectinload(...).where(...)` AttributeError / no-op | Load options have no `.where()` | Use `selectinload(Model.rel.and_(...))` |
- | `SAWarning: TypeDecorator ... will not produce a cache key` | `cache_ok` unset on a custom type | Set `cache_ok = True` |
- | `DetachedInstanceError` | Accessing attributes after the session closed | Load needed data inside the session, or `expire_on_commit=False` |
- | Duplicate rows from a query | Cartesian product from `joinedload` collection | `.unique()` or `selectinload` |
- | Enum `DuplicateObject` on migration | ENUM type recreated | `create_type=False` + let Alembic manage `CREATE TYPE` |
- | Connection errors after idle | Stale pooled connection | `pool_pre_ping=True` + `pool_recycle` |
+ - **Read `references/models.md`** when declaring or changing a model: defaults (`default` / `insert_default` / `server_default` / `onupdate`), relationships and cascades, many-to-many, self-referential, `WriteOnlyMapped`, `type_annotation_map`, `TypeDecorator`, indexes and constraints, hybrids, `column_property`.
+ - **Read `references/loading.md`** when choosing or debugging a loader: `selectinload` vs `joinedload`, `.unique()`, `.and_()` filters, `with_loader_criteria`, `contains_eager`, `raiseload`, `load_only` / `defer`.
+ - **Read `references/querying.md`** when writing SELECTs, filters, bulk INSERT/UPDATE/DELETE (incl. RETURNING and order guarantees), counts, aggregates, subqueries, EXISTS, or recursive CTEs.
+ - **Read `references/async-sessions.md`** when setting up the engine or sessions, deciding transaction boundaries, using `AsyncAttrs` / `refresh` / `run_sync`, running concurrent tasks, attaching events, or tuning the pool.
+ - **Read `references/postgresql.md`** for JSONB / ARRAY / ENUM / UUID types, JSONB operators, upserts, RETURNING, window functions, full-text search, asyncpg vs psycopg, and PgBouncer.
+ - **Read `references/testing.md`** when writing tests or fixtures that touch the database.
+ - **Read `references/alembic.md`** when creating or reviewing migrations: async `env.py`, autogenerate limits, `alembic check`, ENUMs, data migrations.
+ - **Read `references/migration-2.1.md`** when upgrading from 2.0 to 2.1 or when behavior differs between the two.
---
## Keeping This Skill Current
- - **Discover a pattern not covered here while working?** Add it to the relevant section of this skill's `SKILL.md` — edit your local copy, and open a PR to the source repo so everyone benefits.
- - **User hits a SQLAlchemy error not listed?** Add the symptom + cause + fix to the Troubleshooting table.
- - **Upgrading SQLAlchemy?** Check https://docs.sqlalchemy.org/en/20/changelog/ — minor 2.0.x releases add patterns (e.g. `load_only(raiseload=)` in 2.0, `AsyncAttrs` in 2.0.13, hybrid `.inplace` in 2.0.4).
- - **When updating: only add; never remove** unless the changelog explicitly deprecates something.
- - **After any update**, append the date and what changed below.
+ - Found a pattern or error not covered? Add it to the relevant reference file (or the Troubleshooting table) and open a PR to the source repo.
+ - Upgrading? Check the changelogs: SQLAlchemy `https://docs.sqlalchemy.org/en/21/changelog/` and Alembic `https://alembic.sqlalchemy.org/en/latest/changelog.html`.
+ - Every API claim should be traceable to the official docs; append a dated line below for each update.
### Changelog
- - 2026-06-17 — Initial version. Verified against SQLAlchemy 2.0 docs (release line 2.0.51). Covers Sections 1–18, cheatsheet, pre-query checklist, troubleshooting. Key verified corrections vs common mistakes: eager-load filtering uses `relationship.and_()` not `.where()`; `joinedload` collections require `.unique()`; core `Uuid` preferred over `pg.UUID`; `TypeDecorator.cache_ok=True` required; hybrid `.inplace.expression` for type-checker compliance.
- - 2026-06-28 — Packaged into the `omar-skills` marketplace: reworded the "Keeping This Skill Current" path to be install-agnostic, tightened the description triggers to stay SQLAlchemy-specific, and removed a contradictory `IN (subquery)` example in §10.
+ - 2026-06-17 — Initial version, verified against SQLAlchemy 2.0 docs (2.0.51).
+ - 2026-06-28 — Packaged into the `omar-skills` marketplace; install-agnostic wording; removed a contradictory `IN (subquery)` example.
+ - 2026-10-07 — Re-verified against SQLAlchemy 2.1 docs (2.1.3) and Alembic 1.20. Split into `SKILL.md` + `references/`. Added decision guide, common-mistakes table, 2.1 upgrade notes, async Alembic `env.py`, savepoint-based test fixture. Fixed: tz-naive timestamp claim, `onupdate` expiry in async, `"all"` cascade in async, async event targets, `subqueryload` guidance, `DynamicMapped` vs `WriteOnlyMapped`.