Scoping Async SQLAlchemy Sessions in FastAPI
This guide is part of FastAPI SQLAlchemy Pool Configuration. It targets the single most common way an async FastAPI service exhausts its connection pool: a session that is shared across await points or held longer than one request, so checked-out AsyncConnection objects accumulate until the pool blocks. The symptom is a request hang followed by this error after pool_timeout elapses:
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r)
Under asyncio this is rarely a traffic problem. It is a scoping problem: an AsyncSession was created once at module load, or stored on a coroutine that two requests interleave through, and SQLAlchemy’s AsyncSession is explicitly not safe to share across concurrent tasks. This guide covers why scoped_session (the classic thread-local helper) is the wrong tool for async, how to build a correct session-per-request dependency with async_sessionmaker, and how to size and observe the AsyncEngine pool so a leak is visible before it cascades.
Key operational takeaways:
- One
AsyncSessionper request, created and closed inside a FastAPI dependency. Never module-global, never shared acrossawaitpoints by two tasks. scoped_sessionkeys on thread identity, which is meaningless under a single-threaded event loop — every coroutine resolves to the same session. Do not use it for async.- A leaked session keeps its
AsyncConnectionchecked out;pool_size + max_overflowleaks and every subsequent request blocks forpool_timeoutthen raisesTimeoutError. - Create exactly one
AsyncEngine(and oneasync_sessionmaker) per process at startup; the engine owns the pool. Recreating it per request defeats pooling entirely. - Pair
pool_pre_ping=Truewith apool_recycletuned to your provider; see Configuring SQLAlchemy pool_recycle for AWS RDS.
Rapid incident diagnosis
When requests start timing out, determine whether the pool is genuinely saturated by traffic or leaking from bad scoping. The distinguishing signal is whether checked-out connections return to baseline when traffic stops.
Read the pool’s live counters from the engine:
# In an admin/debug endpoint or a periodic log line.
pool = engine.pool
print(pool.status())
# e.g. "Pool size: 5 Connections in pool: 0 Current Overflow: 10 Current Checked out: 15"
If Checked out stays high (at pool_size + max_overflow) even after request volume drops to zero, sessions are not being closed — a scoping leak. If Checked out tracks request concurrency and falls back to zero when idle, the pool is simply undersized for the load.
| Observation | Cause | Direction |
|---|---|---|
Checked out pinned at max while idle |
Leaked sessions never closed | Fix scoping/dependency teardown |
Checked out ≈ concurrent requests, drops when idle |
Pool too small for real load | Raise pool_size/max_overflow or add a proxy |
TimeoutError plus idle in transaction in pg_stat_activity |
Session left a transaction open across await |
Ensure commit/rollback + close per request |
| Errors only after deploy or DB failover | Stale connections, not scoping | pool_pre_ping / pool_recycle |
Cross-check from PostgreSQL. A leaked async session frequently shows as idle in transaction because the session opened a transaction implicitly and never committed or rolled back:
SELECT count(*), state FROM pg_stat_activity
WHERE application_name = 'fastapi-app' GROUP BY state;
Why scoped_session is wrong for async
scoped_session (and its async analog async_scoped_session) maintains a registry keyed by a scope function. The default scope is the current thread. That model assumes the framework gives each unit of work its own thread — which is true for WSGI/threaded Django or Flask, and false for asyncio.
Under FastAPI’s event loop, all coroutines run on one thread. A thread-keyed scoped_session therefore returns the same AsyncSession to every concurrent request handler. Two requests interleaving at an await then issue statements on one session and one underlying connection. SQLAlchemy detects the overlap and raises:
sqlalchemy.exc.IllegalStateChangeError: Method 'commit()' can't be called here;
method '_connection_for_bind()' is already in progress and this would cause
an unexpected state change to
async_scoped_session with a scopefunc of asyncio.current_task can technically give per-task isolation, but it is fragile: the scope must be reset precisely, sub-tasks (asyncio.gather) inherit the wrong session, and cleanup is easy to miss. The idiomatic, robust pattern is not scoped sessions at all — it is a per-request session created by a dependency, where FastAPI’s dependency lifecycle guarantees creation and teardown around exactly one request.
Exact remediation & configuration
Build one engine and one async_sessionmaker at module scope, then yield a fresh session per request via a dependency.
# db.py — one engine, one sessionmaker per process.
from sqlalchemy.ext.asyncio import (
create_async_engine, async_sessionmaker, AsyncSession,
)
engine = create_async_engine(
"postgresql+asyncpg://app:pw@db.internal:5432/appdb",
pool_size=10, # persistent connections held open
max_overflow=20, # extra burst connections, closed when idle
pool_timeout=30, # seconds to wait for a free connection
pool_pre_ping=True, # validate a connection before handing it out
pool_recycle=1800, # recycle before provider idle-kill (see RDS guide)
)
# expire_on_commit=False keeps ORM objects usable after the session closes,
# which matters because the dependency commits then the response serializes.
AsyncSessionLocal = async_sessionmaker(
bind=engine, expire_on_commit=False, autoflush=False,
)
# deps.py — session-per-request dependency with guaranteed teardown.
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from db import AsyncSessionLocal
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session: # opens session + checks out conn lazily
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# `async with` closes the session on exit, returning the
# connection to the pool — even on exception.
# routes.py — inject, use, never store the session beyond the request.
from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_session
app = FastAPI()
@app.get("/users/{user_id}")
async def read_user(user_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
The rules this enforces:
- The session is created when the request starts and closed when it ends. There is no global session and no
scoped_sessionregistry to leak. - The connection is checked out lazily on first query and returned by
async with ... as sessionon exit, including on exception, which is what prevents theidle in transactionleak. - Never pass
sessioninto a background task that outlives the request. Background work must open its own session fromAsyncSessionLocal, because the request’s session closes when the response is sent. - Never run
asyncio.gatherwith the same session across the gathered coroutines — concurrent statements on one session corrupt its state. Give each concurrent unit its own session, or serialize them on the one session.
Apply changes with zero downtime by rolling Uvicorn/Gunicorn workers; the engine is recreated per process at startup, so a graceful worker restart drains in-flight requests and rebuilds the pool cleanly.
For lifecycle work beyond per-request scoping — connect/checkout/checkin event hooks for instrumentation — see ORM Connection Lifecycle Hooks.
Validation & verification
Prove three properties: connections return to baseline when idle, no session outlives its request, and no idle in transaction accumulates.
Watch the pool drain after a load test. Run a burst, then idle, and log engine.pool.status() once per second:
import asyncio, logging
async def log_pool():
while True:
logging.info(engine.pool.status())
await asyncio.sleep(1)
Current Checked out must return to 0 within a couple of seconds of traffic stopping. A non-zero floor at idle is a leak.
Confirm from the database that nothing lingers in a transaction:
SELECT pid, state, query_start, left(query, 60)
FROM pg_stat_activity
WHERE application_name = 'fastapi-app' AND state = 'idle in transaction'
ORDER BY query_start;
This should return zero rows at idle. Rows here mean a code path commits/rolls back inconsistently or holds a session across await.
Load-test assertion: drive pool_size + max_overflow + 5 concurrent requests against an endpoint that does one query each. With correct per-request scoping the surplus requests queue and complete within pool_timeout; none raise TimeoutError. If you see TimeoutError at this concurrency, sessions are leaking rather than recycling.
Frequently Asked Questions
Why is async_scoped_session discouraged when SQLAlchemy provides it?
scopefunc (typically asyncio.current_task) that must be reset on exactly the right boundaries, and any sub-task spawned with gather or create_task inherits a scope it should not. A per-request dependency that yields a fresh AsyncSession and closes it in a finally/async with is simpler and harder to get wrong, and it aligns with FastAPI’s dependency lifecycle. Reserve async_scoped_session for code that genuinely cannot pass a session through call arguments.Can two coroutines share one AsyncSession if I’m careful with locks?
AsyncSession and its underlying AsyncConnection are designed for a single logical sequence of operations. Sharing across concurrent tasks forces you to serialize every statement with a lock, which removes the concurrency you wanted and reintroduces IllegalStateChangeError the moment the locking is imperfect. Give each concurrent unit its own session from the same async_sessionmaker; the engine’s pool exists precisely to make that cheap.Does expire_on_commit=False risk stale data?
expire_on_commit=False, attributes loaded before commit() remain accessible after the session closes, which is what lets your response serializer read the object after the dependency commits. The trade-off is that those attributes reflect the state at load time; if you need post-commit database values, re-query in a fresh session. For connection-pool health it is the correct setting in the request-scoped pattern.How big should pool_size and max_overflow be for an async service?
pool_size near your expected steady concurrency and max_overflow for burst, keeping pool_size + max_overflow per process below the database’s max_connections divided by process count. If you front the database with PgBouncer or RDS Proxy, the per-process pool can be smaller because the proxy multiplexes.Why do I see “idle in transaction” even though I never call begin()?
AsyncSession opens a transaction implicitly on the first statement. If the request finishes without commit() or rollback() and without closing the session, that transaction stays open and PostgreSQL reports idle in transaction. The async with AsyncSessionLocal() dependency pattern closes the session on exit, which rolls back any open transaction, eliminating the leak.Related
- FastAPI SQLAlchemy Pool Configuration — the parent topic covering the full async pool setup.
- Configuring SQLAlchemy pool_recycle for AWS RDS — recycling stale connections that survive correct scoping.
- ORM Connection Lifecycle Hooks — connect/checkout/checkin events for instrumenting session and connection lifecycle.