The pitfalls that bite teams new to SQLAlchemy 2.0 — and how to fix them quickly.
N+1
Symptom: a loop over results triggers a query per row.
users = (await s.execute(select(User))).scalars().all()
for u in users:
print(u.posts) # one query per user
Fix:
stmt = select(User).options(selectinload(User.posts))
Or set lazy="raise" on the relationship to catch all N+1 in dev/tests.
DetachedInstanceError
Symptom: attribute access raises after the session closes.
async with AsyncSessionLocal() as s:
user = await s.get(User, 1)
print(user.posts) # DetachedInstanceError
Fix: load needed attributes inside the session (selectinload), keep session open, or set expire_on_commit=False.
MissingGreenlet
Symptom: MissingGreenlet raised on attribute access in async.
Cause: lazy-load triggered in async context without awaitable_attrs or eager-load.
Fix:
# Eager-load
stmt = select(User).options(selectinload(User.posts))
# Or
posts = await user.awaitable_attrs.posts
# Or set lazy="raise" so it's loud
posts: Mapped[list[Post]] = relationship(lazy="raise")
IntegrityError on unique constraint
Symptom: app crashes with psycopg.errors.UniqueViolation (or similar).
Fix: catch and map:
from sqlalchemy.exc import IntegrityError
try:
await session.commit()
except IntegrityError as e:
await session.rollback()
if "users_email_key" in str(e.orig):
raise EmailTaken
raise
Stale data after commit
Symptom: after await session.commit(), reading attributes re-queries DB.
Cause: expire_on_commit=True (default).
Fix:
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
For FastAPI / web apps: always False.
Pool exhaustion
Symptom: requests hang; TimeoutError: QueuePool limit of size N overflow N reached.
Cause:
- Long-held sessions during external HTTP calls.
- N+1 keeping connections busy.
- Pool too small.
Fix: acquire late, release early; pool sizing; PgBouncer.
# BAD
async with AsyncSessionLocal() as s:
user = await s.get(User, 1)
await some_http_call() # connection held
await s.commit()
# GOOD
async with AsyncSessionLocal() as s:
user = await s.get(User, 1)
await some_http_call()
async with AsyncSessionLocal() as s:
# ...
Sync driver in async engine
# WRONG
engine = create_async_engine("postgresql+psycopg2://...") # psycopg2 is sync
Fix: use asyncpg or psycopg (v3).
Forgetting to flush before reading auto-generated IDs
session.add(user)
print(user.id) # None!
Fix:
session.add(user)
await session.flush()
print(user.id) # set
Mutable default
class M(Base):
tags: Mapped[list[str]] = mapped_column(JSON, default=[]) # SHARED!
Fix:
tags: Mapped[list[str]] = mapped_column(JSON, default=lambda: [])
CONCURRENTLY in transaction (Postgres)
op.execute("CREATE INDEX CONCURRENTLY ...") # fails in transaction
Fix in alembic.ini:
[alembic]
transaction_per_migration = false
Forgetting to import models in env.py
Symptom: alembic revision --autogenerate produces empty migration.
Fix: explicitly import models in env.py so Base.metadata is populated:
import src.app.models # noqa
Bulk update doesn’t fire ORM events
await session.execute(update(User).where(...).values(...))
# before_update events DON'T fire
Fix: if you need events, fetch then mutate; or use SQL triggers; or pass execution_options={"synchronize_session": "fetch"}.
SELECT * is slow on wide tables
Fix: load_only(...) or specify columns.
stmt = select(User).options(load_only(User.id, User.email))
Filter not using index
Symptom: query is slow; EXPLAIN shows seq scan.
Cause: function on indexed column.
# BAD — bypasses index
stmt = select(User).where(func.lower(User.email) == "x")
# GOOD — use expression index
__table_args__ = (Index("ix_users_lower_email", func.lower(User.email)),)
# Or citext column (Postgres)
Forgetting commit
session.add(user)
# session closes; nothing persists
Fix: always await session.commit() before exit (or use session.begin() block).
Cross-session object usage
async with AsyncSessionLocal() as s1:
user = await s1.get(User, 1)
async with AsyncSessionLocal() as s2:
s2.add(user) # detached; behavior depends on state
Fix: one object lives in one session at a time. Use session.merge() if you must.
Hot reload while engine is alive
In dev, file changes trigger reload while engine has connections. Pool isn’t disposed → leftover connections.
Fix: lifespan with await engine.dispose() on shutdown; dev reload tools call shutdown.
Read this next
If you want my N+1 detector + DetachedInstance guard middleware, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .