Skip to content
Back to skills

Sqlalchemy Patterns

ASecurity

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...

  • 3 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added September 6, 2026
ai-agentspythonsqltestingdebuggingapidatabase

Works with

  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 6, 2026

npx -y skills add OmarSaleh506/skills --skill sqlalchemy-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Sqlalchemy Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Sqlalchemy Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/omarsaleh506-sqlalchemy-patterns/badge)](https://www.skillsdirectory.com/skills/omarsaleh506-sqlalchemy-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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)

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()`.

> 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

| Rule | Do this |
|---|---|
| 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 |
| 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 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.
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.

---

## Decision Guide

**Which loader?**

| 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 |

**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 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(AsyncAttrs, DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)
    type_annotation_map = {datetime.datetime: DateTime(timezone=True)}

class User(Base):
    __tablename__ = "user"
    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")

class Order(Base):
    __tablename__ = "order"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
    user: Mapped[User] = relationship(back_populates="orders")

engine = create_async_engine("postgresql+asyncpg://user:pw@host/db", pool_pre_ping=True)
async_session = async_sessionmaker(engine, expire_on_commit=False)

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 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.*

---

## Common Mistakes

| 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` |

---

## Troubleshooting

| 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`) |

---

## References — read the one you need

- **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

- 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 (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`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.