SQLAlchemy Asyncio Session-Per-Request Patterns¶
AsyncSession behaves nothing like the connection objects around it. A connection pool is designed to be shared; a session is not. It holds identity-mapped objects, a pending transaction, and — critically — a single connection it is in the middle of using, which means two coroutines touching the same session concurrently produce interleaved statements on one connection and errors like InterfaceError: another operation is in progress or, worse, silent cross-request data mixing.
The rule that avoids all of it is one session per unit of work — usually one request — created at entry, committed or rolled back at exit, never captured in a global and never shared between tasks. This guide implements that shape, covers the lazy-loading trap that async surfaces differently from sync SQLAlchemy, sizes the engine's pool against your concurrency, and disposes of the engine cleanly at shutdown.
Prerequisites¶
- Python 3.11+ with SQLAlchemy 2.x and an async driver:
pip install "sqlalchemy[asyncio]" asyncpg
- The async database drivers overview, and connection pooling and keep-alive for the pool behind the engine.
1. One engine, one sessionmaker, many sessions¶
The engine (and its pool) is process-wide; the session is per unit of work.
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
engine = create_async_engine( # ONE per process, holds the pool
"postgresql+asyncpg://app@db/app",
pool_size=20, max_overflow=10, pool_timeout=2.0, pool_pre_ping=True,
)
Session = async_sessionmaker(engine, expire_on_commit=False) # a factory, not a session
expire_on_commit=False matters more in async than in sync code: with the default, attribute access after a commit triggers a refresh, and in async that refresh is an implicit await that cannot happen from a plain attribute read — so it raises. Creating a second engine per request is the other common mistake: each brings its own pool, so the process ends up with hundreds of connections. Verify: log engine.pool.status() at intervals; the total connection count should stay near pool_size, not scale with request rate.
2. Scope the session to the request¶
Create it at the boundary, commit once, close always.
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
app = FastAPI()
async def get_session() -> AsyncIterator[AsyncSession]:
async with Session() as session: # closes and returns the connection
try:
yield session
await session.commit() # one commit per request
except Exception:
await session.rollback()
raise
@app.get("/users/{user_id}")
async def read_user(user_id: int, session: AsyncSession = Depends(get_session)):
user = await session.get(User, user_id)
return {"id": user.id, "email": user.email}
The async with is what guarantees the connection returns to the pool on every path, including cancellation — a session leaked by an early return holds a pooled connection until garbage collection, which under load exhausts the pool while the database itself is idle. Commit at the boundary rather than inside handlers so the transaction's extent is obvious. Verify: cancel a request mid-query (client disconnect) and confirm the pool's checked-out count returns to zero.
3. Never share a session across tasks¶
Concurrency inside a request needs concurrent sessions, not a shared one.
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
# WRONG — two coroutines on one session, one connection, interleaved statements
async def broken(session: AsyncSession, ids: list[int]):
return await asyncio.gather(*(session.get(User, i) for i in ids))
# RIGHT — one session per concurrent task, each with its own connection
async def one(user_id: int):
async with Session() as s:
return await s.get(User, user_id)
async def fixed(ids: list[int]):
return await asyncio.gather(*(one(i) for i in ids))
# BETTER — one session, one round trip
async def best(session: AsyncSession, ids: list[int]):
result = await session.execute(select(User).where(User.id.in_(ids)))
return result.scalars().all()
Note that the fan-out version consumes one pooled connection per concurrent task, so it must be bounded — twenty parallel queries against a twenty-connection pool leaves nothing for other requests. Usually the right answer is not concurrency at all but a single query, which is faster than any of the alternatives. Verify: the broken version raises InterfaceError under concurrency; the fixed one does not, and the pool's checked-out count matches the fan-out width.
4. Eliminate lazy loads before they raise¶
In async SQLAlchemy, a lazy relationship access outside an active session raises MissingGreenlet instead of silently emitting a query.
from sqlalchemy import select
from sqlalchemy.orm import selectinload
async def orders_with_items(session: AsyncSession, user_id: int):
stmt = (select(Order)
.where(Order.user_id == user_id)
.options(selectinload(Order.items))) # eager: one extra query, no lazy IO
return (await session.execute(stmt)).scalars().all()
Three fixes, in order of preference: eager-load with selectinload/joinedload in the query, configure lazy="selectin" on the relationship when it is always needed, or call await session.refresh(obj, ["items"]) explicitly. Serialising ORM objects after the session closes is where this usually bites — convert to plain dicts or Pydantic models inside the session scope. Verify: serialise every response model with the session already closed; any MissingGreenlet is a missing eager load.
5. Size the pool and dispose of the engine¶
Pool arithmetic is the same as any other pool, and shutdown needs one explicit call.
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
# concurrency that can touch the DB simultaneously, not total request rate
engine = create_async_engine(
URL,
pool_size=20, # steady-state connections held
max_overflow=10, # burst headroom, closed when idle
pool_timeout=2.0, # fail fast rather than queue invisibly
pool_recycle=1800, # avoid server-side idle disconnects
)
async def shutdown() -> None:
await engine.dispose() # close every pooled connection
Check the total against the server: pool_size + max_overflow multiplied by your replica count must stay under PostgreSQL's max_connections, or the first burst after a deploy fails at the database rather than in your pool. And dispose() belongs in the shutdown sequence — without it, connections are closed only when the process exits, which can leave a server holding sessions during a rolling restart. Verify: after shutdown, the database reports no remaining sessions from that instance.
Verification¶
- Connections return. Checked-out connections drop to zero between requests, including after cancelled ones.
- No interleaving errors. Load testing with per-request concurrency produces no
InterfaceErrororMissingGreenlet. - Pool math holds. Total connections across replicas stays comfortably under the server's
max_connections.
Pitfalls and edge cases¶
- A session in a module-level global. Every request shares one connection and one identity map; it fails as soon as two requests overlap.
gatherover one session. Interleaved statements on a single connection; give each task its own session or use one query.expire_on_commit=True(the default). Post-commit attribute access needs an implicit await and raises in async code.- Serialising ORM objects after the session closes. Any un-loaded relationship raises
MissingGreenlet. - An engine per request. Each carries its own pool; connection count then scales with traffic.
- No
pool_timeout. Exhaustion becomes an unbounded wait instead of a fast, countable failure. - Forgetting
engine.dispose(). Connections linger on the server through restarts. - Long transactions. A session open across an external HTTP call holds its connection and its locks for that entire call.
Frequently Asked Questions¶
Can an AsyncSession be shared between concurrent tasks?
No. A session owns one connection and one identity map, so two tasks using it concurrently interleave statements on the same connection and typically raise InterfaceError, or silently mix state between logical operations. Give each concurrent task its own session from the sessionmaker — bounding the fan-out, since each takes a pooled connection — or, better, replace the fan-out with a single query.
Why do I get MissingGreenlet errors with SQLAlchemy asyncio?
Because a lazy-loaded attribute was accessed outside an active async session. In synchronous SQLAlchemy that would silently emit a query; in async there is no way to await from a plain attribute read, so it raises. Eager-load the relationship with selectinload or joinedload, or convert the object to a plain structure while the session is still open.
Should expire_on_commit be False for async sessions?
Usually yes. With the default of True, every attribute access after a commit triggers a refresh, which in async code needs an await that attribute access cannot perform. Setting expire_on_commit=False on the sessionmaker keeps loaded attributes usable after commit, which is what a session-per-request handler almost always wants.
How should the SQLAlchemy async pool be sized?
Size pool_size to the concurrent database work you actually run, not to your request rate, and add a modest max_overflow for bursts. Then check that (pool_size + max_overflow) times replica count stays under the server's max_connections. Set a short pool_timeout so exhaustion is a fast countable error rather than invisible latency.
Related¶
- Async Database Drivers — the parent overview: drivers, pools, and blocking pitfalls.
- Avoiding event loop blocking with asyncpg — the driver beneath this ORM layer.
- Diagnosing connection pool exhaustion in async clients — when the pool is the bottleneck.