AnyLearn
All lessons
Programmingadvanced

SQLAlchemy 2.0 async ORM in production

SQLAlchemy 2.0 from the production angle. The engine/session/transaction layering, the typed declarative, the identity map, the N+1 query problem, async-only gotchas (no lazy loading), savepoint nesting, and the connection-pool knobs that decide whether the backend survives load.

Not signed in โ€” your progress and quiz score won't be saved.
Lesson progress1 / 8

Why 2.0, and why async

SQLAlchemy 2.0 unifies the Core SELECT/INSERT API with the ORM, replaces Query with select(), and ships first-class async support across the stack.

Two consequences for backend code:

  • One API surface. session.execute(select(User).where(...)) looks identical to a Core query against the same engine. No more session.query(User) vs engine.execute(text("...")) split.
  • An honest async path. AsyncEngine + AsyncSession wrap the sync ORM with awaitable operations. Pre-2.0 async stories were either ad-hoc wrappers or non-ORM. Now the async session is mainstream and the patterns are settled.
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import select

engine = create_async_engine("postgresql+asyncpg://...")

async def get_user(uid: int) -> User:
    async with AsyncSession(engine) as session:
        result = await session.execute(select(User).where(User.id == uid))
        return result.scalar_one()

The driver matters: async needs an async driver (asyncpg, aiomysql, aiosqlite). The sync psycopg2 will not work behind create_async_engine โ€” SQLAlchemy rejects the URL at construction time.

Full lesson text

All 8 steps on one page โ€” for reading, reference, and search.

Show

1. Why 2.0, and why async

SQLAlchemy 2.0 unifies the Core SELECT/INSERT API with the ORM, replaces Query with select(), and ships first-class async support across the stack.

Two consequences for backend code:

  • One API surface. session.execute(select(User).where(...)) looks identical to a Core query against the same engine. No more session.query(User) vs engine.execute(text("...")) split.
  • An honest async path. AsyncEngine + AsyncSession wrap the sync ORM with awaitable operations. Pre-2.0 async stories were either ad-hoc wrappers or non-ORM. Now the async session is mainstream and the patterns are settled.
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import select

engine = create_async_engine("postgresql+asyncpg://...")

async def get_user(uid: int) -> User:
    async with AsyncSession(engine) as session:
        result = await session.execute(select(User).where(User.id == uid))
        return result.scalar_one()

The driver matters: async needs an async driver (asyncpg, aiomysql, aiosqlite). The sync psycopg2 will not work behind create_async_engine โ€” SQLAlchemy rejects the URL at construction time.

2. Engine, session, transaction โ€” the three layers

Engines, sessions, and transactions are different things with different lifetimes:

  • Engine โ€” a connection-pool manager. Process-lifetime. One per database URL. Cheap to share; expensive to construct.
  • Session โ€” a unit-of-work tracker. Request-lifetime. Holds the identity map and the pending changes. Cheap to create; dangerous to share across requests.
  • Transaction โ€” the actual DB transaction. Opened explicitly with session.begin() or implicitly on the first session.execute().
# Process startup โ€” one engine with a pool.
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=10)

# Per request โ€” one session, one transaction.
async with AsyncSession(engine) as session:
    async with session.begin():
        session.add(User(email="a@b.com"))
    # On exit success: COMMIT. On exception: ROLLBACK.

Mixing layers is the classic mistake: an engine per request kills the pool, a session held for the whole process holds locks forever and grows the identity map without bound. Anchor the engine at process start, build one session per request.

3. Typed declarative models

from datetime import datetime
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True)
    created_at: Mapped[datetime]
    posts: Mapped[list["Post"]] = relationship(back_populates="author")

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    title: Mapped[str]
    author: Mapped[User] = relationship(back_populates="posts")

Mapped[T] is read at class-creation time. Mapped[int] becomes a NOT NULL integer column. Mapped[int | None] allows NULL. Mapped[list[Post]] is a one-to-many relationship. The annotation drives both the schema and the static type of user.id, so the same line that defines the column also gives mypy/pyright enough to type-check the queries against it.

4. Identity map and unit of work

SQLAlchemy is not a "run a query, get a row" layer. It is a unit of work: the session tracks every loaded object and every modification, and commit() flushes the minimal set of statements that reconciles them.

async with AsyncSession(engine) as session:
    u1 = await session.get(User, 1)
    u2 = await session.get(User, 1)
    assert u1 is u2          # same Python object โ€” identity map at work

    u1.email = "new@example.com"
    # No SQL yet. The session marks u1 dirty.

    other = User(email="x@y.com")
    session.add(other)
    # No SQL yet. The session marks `other` pending.

    await session.commit()
    # Now: BEGIN, UPDATE users WHERE id=1, INSERT INTO users, COMMIT.

The identity map means two session.get(User, 1) calls return the same Python object โ€” convenient for relationship navigation, hazardous if you hold a session across requests (stale data, locks held, memory growth). Closing the session drops the identity map; the next session rebuilds it.

5. N+1 โ€” the cost of natural-looking code

The single most expensive bug in any ORM-backed backend:

async def list_users():
    result = await session.execute(select(User))
    users = result.scalars().all()
    return [{"email": u.email, "posts": len(u.posts)} for u in users]

Reading u.posts triggers SELECT * FROM posts WHERE author_id = ? per user. 50 users โ†’ 51 queries. The fix is to tell the session to load the relationship up front:

from sqlalchemy.orm import selectinload, joinedload

# selectinload โ€” separate SELECT ... IN (...). Best for to-many.
result = await session.execute(
    select(User).options(selectinload(User.posts))
)

# joinedload โ€” single LEFT JOIN. Best for to-one or small to-many.
result = await session.execute(
    select(Post).options(joinedload(Post.author))
)

Detection in dev: create_async_engine(URL, echo="debug") logs every statement โ€” one SELECT users followed by 50 SELECT posts is the visual fingerprint. In production: slow-query logs or APM (Datadog, OpenTelemetry instrumentation for SQLAlchemy) aggregate the same pattern.

6. Async-only gotcha โ€” no implicit lazy loading

The rule that catches every team migrating from sync SQLAlchemy: AsyncSession does not allow lazy loading. Accessing an unloaded relationship would issue a synchronous query inside an async context, defeating the whole point of async.

async with AsyncSession(engine) as session:
    u = await session.get(User, 1)
    print(u.posts)   # sqlalchemy.exc.MissingGreenlet: not loaded

Three correct patterns:

  • Eager load up front with selectinload(User.posts) or joinedload(...) in the select().
  • await session.refresh(u, ["posts"]) to pull specific relationships after the fact.
  • await session.run_sync(callback) to drop into sync mode for a tightly scoped block โ€” escape hatch, not a default.

The error message โ€” MissingGreenlet: greenlet_spawn has not been called โ€” is the migration-playbook smell. Default to eager loading; the up-front query is almost always cheaper than the latency hit of a forgotten relationship load.

7. Transactions โ€” explicit, nested, clean

The session.begin() block is the cleanest unit of work:

async with AsyncSession(engine) as session:
    async with session.begin():
        session.add(User(email="a@b.com"))
        await session.execute(
            update(Order).where(Order.id == oid).values(status="paid")
        )
    # Exit success โ†’ COMMIT. Exit on exception โ†’ ROLLBACK.

Nested begin_nested() opens a SAVEPOINT โ€” useful when a sub-step is allowed to fail without aborting the outer transaction:

async with session.begin():
    session.add(parent)
    try:
        async with session.begin_nested():       # SAVEPOINT
            session.add(child_that_might_violate)
    except IntegrityError:
        pass   # SAVEPOINT rolled back; outer transaction alive

Don't call await session.commit() inside a begin() block โ€” it is redundant and raises one of the library's more confusing exceptions. Pick one shape: either the begin() context manager, or manual commit() / rollback(). Mixing them is the source of most "transaction already begun" errors.

8. Pool settings that decide whether you survive load

Three knobs that decide whether the backend stays up:

  • pool_size โ€” steady-state idle connections kept open. Default 5; production is usually 10โ€“50 per worker, calibrated against Postgres's max_connections ร— number of workers. Over-provisioning kills the database.
  • max_overflow โ€” extra connections opened on demand above pool_size. Total cap = pool_size + max_overflow. Set so peak load ร— workers stays under the database connection cap.
  • pool_pre_ping=True โ€” issues a cheap SELECT 1 before handing the connection over. Catches connections silently killed by load balancers (PgBouncer, AWS RDS Proxy, NAT idle timeouts). Without it, the first query of the next request fails with OperationalError: server closed the connection unexpectedly.
engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
    pool_recycle=1800,   # close + reopen every 30 min
)

Behind PgBouncer in transaction mode, set poolclass=NullPool and let PgBouncer pool. Stacking pools is how you exhaust Postgres's max_connections for the whole cluster โ€” every worker holds 30 conns, every PgBouncer client holds another 30, and the database refuses new connections at 8 am Monday.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. You configure `create_async_engine("postgresql+psycopg2://...")` (the sync driver). What happens?
    • The engine works in synchronous mode under the async API
    • SQLAlchemy rejects the URL โ€” `create_async_engine` requires an async driver
    • It works but every query blocks the event loop
    • It works for SELECTs and fails on writes
  2. Within one `AsyncSession`, `await session.get(User, 1)` is called twice. Why does `u1 is u2` evaluate to True?
    • Python interns small integers, so the rows are deduplicated
    • The session's identity map returns the already-loaded object on the second call
    • SQLAlchemy compares row hashes; identical hashes share an object
    • Coincidence โ€” `is` happens to work for the first row only
  3. You have 50 users; iterating each user's `posts` issues a SELECT per user. Which loading option is the right fix for a one-to-many relationship?
    • `joinedload(User.posts)` โ€” a single LEFT JOIN
    • `selectinload(User.posts)` โ€” a follow-up SELECT with WHERE author_id IN (...)
    • `lazyload(User.posts)` โ€” only queries when accessed
    • `raiseload(User.posts)` โ€” raises if accessed; nothing to fix
  4. Inside an `AsyncSession`, a handler reads `user.posts` on a relationship that wasn't pre-loaded. What does SQLAlchemy do?
    • Quietly issues the SELECT and blocks the event loop for the duration
    • Returns an empty list and logs a warning
    • Raises `MissingGreenlet` โ€” async sessions forbid implicit lazy loading
    • Schedules the SELECT to run after the handler returns
  5. Why is `pool_pre_ping=True` standard for Postgres backends sitting behind PgBouncer or RDS Proxy?
    • It warms the JIT for the SQL parser before serving requests
    • It validates that the pooled connection is still alive before use, so the first query of a request doesn't fail when an intermediary closed the socket
    • It pre-allocates `pool_size` connections at engine creation
    • It enforces TLS handshake renegotiation on every request

Related lessons