SQLAlchemy 2.0 skill for Codex
Typed models, scoped sessions, lazy loads that raise. Codex writes SQLAlchemy in 2.0 style: typed Mapped columns, select() through a session scoped to one unit of work, and relationships set to raise rather than lazy load. It converts legacy session.query code with a mapping table, fixes MissingGreenlet in async FastAPI apps by loading eagerly, and treats Alembic autogenerate as a draft, rewriting renames and concurrent indexes before anything runs.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- sqlalchemy-2 Installs to ~/.agents/skills/sqlalchemy-2/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install sqlalchemy-2 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project sqlalchemy-2 Installs to .agents/skills/sqlalchemy-2/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/sqlalchemy-2.zipunzip -o sqlalchemy-2.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) sqlalchemy-2 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $sqlalchemy-2, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and migrates Python data access to SQLAlchemy 2.0 style - DeclarativeBase with Mapped and mapped_column, select() with session.scalars and session.execute, explicit session and transaction scopes, eager loading with selectinload, joinedload and raiseload, AsyncSession with asyncpg, bulk insert and upsert - and manages schema with Alembic autogenerate, naming conventions and reviewed revisions. Use when writing models or queries, converting legacy session.query code, setting up async SQLAlchemy in FastAPI, hitting MissingGreenlet or DetachedInstanceError, or creating Alembic migrations.
Use it when
- Defining SQLAlchemy models or relationships
- Migrating 1.x Query API code to 2.0
- Setting up AsyncSession for FastAPI or other asyncio apps
- Writing or fixing Alembic revisions
Use something else when
- The project uses the Django ORM: use django-orm-performance
- Broader FastAPI app structure beyond the database layer: use fastapi-services
How to check the result
Statement logs show a constant query count for the changed path, alembic upgrade head on an empty database followed by alembic check reports no drift, and type checks pass.
The mistake it prevents
Accepting an autogenerated Alembic revision that turns a column rename into drop plus add.
Done well means: models use typed Mapped[...] annotations, every query is a select()
executed through a session whose lifetime is one unit of work, relationships load only
when the code asks (lazy loads raise instead of firing silently), and every schema change
is an Alembic revision someone has read.
Inspect first
- Read
AGENTS.md, thenpyproject.tomlor requirements:sqlalchemyversion (2.0.x or 2.1), driver (psycopg,asyncpg,pymysql,aiosqlite),alembic. - Find where the engine and session factory live, and how a request or job gets a session (FastAPI dependency, Flask extension, context manager).
- Grep for legacy patterns to scope a migration:
rg -n "session\.query\(|declarative_base\(|\.get\(|autocommit=|engine\.execute" --type py. - Read
alembic.ini,migrations/env.py(oralembic/env.py) and the latest revision.
Models
from datetime import datetime
from decimal import Decimal
from sqlalchemy import ForeignKey, MetaData, String, Numeric, func, Index
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
NAMING = {
"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):
metadata = MetaData(naming_convention=NAMING)
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True)
orders: Mapped[list["Order"]] = relationship(back_populates="customer", lazy="raise")
class Order(Base):
__tablename__ = "orders"
__table_args__ = (Index("ix_orders_customer_created", "customer_id", "created_at"),)
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id", ondelete="RESTRICT"))
total: Mapped[Decimal] = mapped_column(Numeric(12, 2))
note: Mapped[str | None] # Optional -> nullable
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
customer: Mapped[Customer] = relationship(back_populates="orders", lazy="raise")
Mapped[str]is NOT NULL;Mapped[str | None]is nullable. Let annotations drive nullability.- Set a naming convention before the first migration; unnamed constraints make Alembic downgrades and renames painful.
- Prefer
server_defaultfor values the database should own;default=only applies to ORM inserts. lazy="raise"(orraiseload("*")per query) turns accidental N+1 into an error.
Engine and session scope
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine(DATABASE_URL, pool_size=5, max_overflow=5, pool_pre_ping=True)
SessionLocal = sessionmaker(engine, expire_on_commit=False)
def place_order(customer_id: int, total: Decimal) -> int:
with SessionLocal() as session, session.begin(): # commits or rolls back
order = Order(customer_id=customer_id, total=total)
session.add(order)
session.flush() # assigns order.id inside the transaction
return order.id
- One session per request or job; never a module-level global session shared across threads.
session.begin()as a context manager replaces manual commit/rollback.expire_on_commit=Falsekeeps loaded attributes readable after commit (required for async).
Querying the 2.0 way
| Legacy (1.x) | 2.0 |
|---|---|
session.query(User).filter_by(id=1).first() | session.scalars(select(User).filter_by(id=1)).first() |
session.query(User).get(1) | session.get(User, 1) |
query.all() | session.scalars(stmt).all() |
session.query(User.id, User.email) | session.execute(select(User.id, User.email)).all() (Row tuples) |
query.count() | session.scalar(select(func.count()).select_from(User)) |
query.update({...}) | session.execute(update(User).where(...).values(...)) |
engine.execute(sql) | with engine.connect() as conn: conn.execute(text(sql), params) |
from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload
stmt = (
select(Order)
.where(Order.customer_id == cid, Order.created_at >= since)
.options(joinedload(Order.customer), selectinload(Order.items))
.order_by(Order.created_at.desc(), Order.id.desc())
.limit(50)
)
orders = session.scalars(stmt).unique().all()
Eager loading choice:
| Relationship | Loader |
|---|---|
| many-to-one / one-to-one | joinedload |
| one-to-many / many-to-many collections | selectinload (one extra IN query) |
| Anything not needed by this code path | raiseload / lazy="raise" |
| Only some columns | load_only(Order.id, Order.total) |
joinedload on a collection requires .unique() on the result. Do not joinedload large
collections: rows multiply.
Bulk writes and upserts
from sqlalchemy import insert
from sqlalchemy.dialects.postgresql import insert as pg_insert
session.execute(insert(Order), [{"customer_id": 1, "total": 10}, ...]) # executemany fast path
stmt = pg_insert(Customer).values(rows)
stmt = stmt.on_conflict_do_update(index_elements=[Customer.email], set_={"name": stmt.excluded.name})
session.execute(stmt)
Chunk large loads (for example 1 000-5 000 rows per execute). For very large imports use
COPY through the driver.
Async
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
engine = create_async_engine("postgresql+asyncpg://...", pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_session(): # FastAPI dependency
async with SessionLocal() as session:
yield session
async def list_orders(session: AsyncSession, cid: int) -> list[Order]:
result = await session.scalars(
select(Order).where(Order.customer_id == cid).options(selectinload(Order.items)))
return list(result)
MissingGreenletmeans a lazy load or expired attribute was touched outsideawait. Fix by eager loading in the query orawait obj.awaitable_attrs.items(mixAsyncAttrsintoBase), not by wrapping inrun_sync.- One
AsyncSessionper task; never share it across concurrentasyncio.gathercalls. - Behind PgBouncer in transaction mode with asyncpg, disable both statement caches:
?prepared_statement_cache_size=0on the URL (SQLAlchemy’s cache) andconnect_args={"statement_cache_size": 0}(asyncpg’s). The asyncpg dialect docs cover unique statement names if errors persist. On serverless, considerpoolclass=NullPool.
Alembic
alembic revision --autogenerate -m "add orders customer index"
alembic upgrade head
alembic check # fails if models and database differ (no new revision needed)
alembic upgrade head --sql > plan.sql # offline SQL for review
- In
env.py, settarget_metadata = Base.metadataand import every model module so autogenerate sees them. For async projects start fromalembic init -t async. - Autogenerate does not detect column or table renames (it emits drop + add), anonymous constraint changes, or some server default changes. Read and fix every revision.
- SQLite needs
render_as_batch=Trueincontext.configurefor ALTER support. - Large Postgres tables: create indexes with
op.create_index(..., postgresql_concurrently=True)insidewith op.get_context().autocommit_block():. - Data migrations go in their own revision with batched updates; do not import ORM models
into revisions (they change later); use
sa.table()stubs or SQL. alembic downgradeon shared environments and any revision that drops columns or tables need explicit user confirmation.- Two heads after merging branches:
alembic merge heads -m "merge".
Gotchas
session.execute(select(User))returns Rows of one element; usescalars()for ORM objects.- Passing an f-string to
text()is SQL injection; bind withtext("... :id")and params. DetachedInstanceErrorafter the session closes: load what you need inside the scope or keepexpire_on_commit=False.pool_sizetimes worker processes must stay under the database’s connection limit.Decimalcolumns returnDecimal; JSON serializers need a handler.
Verification
- Run with
echo=True(or thesqlalchemy.enginelogger at INFO) on the changed path and count statements; they must not grow with result size. alembic upgrade headon an empty database, thenalembic checkreports no drift.- Test
alembic downgrade -1 && alembic upgrade headlocally for the new revision. - Type-check with mypy or pyright;
Mappedannotations make query results typed.
Output
**Models changed:** <classes/columns/relationships>
**Revision:** migrations/versions/<rev>_<slug>.py (autogenerate edited: <what and why>)
**Loading strategy:** <loaders added, raiseload coverage>
**Verified:** upgrade/downgrade on fresh DB, alembic check, query count, tests What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "SQLAlchemy 2.0"
short_description: "Typed models, scoped sessions, lazy loads that raise"
default_prompt: "$sqlalchemy-2 Convert our session.query calls to SQLAlchemy 2.0 select() style" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.