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 AsyncSession per request, created and closed inside a FastAPI dependency. Never module-global, never shared across await points by two tasks.
  • scoped_session keys 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 AsyncConnection checked out; pool_size + max_overflow leaks and every subsequent request blocks for pool_timeout then raises TimeoutError.
  • Create exactly one AsyncEngine (and one async_sessionmaker) per process at startup; the engine owns the pool. Recreating it per request defeats pooling entirely.
  • Pair pool_pre_ping=True with a pool_recycle tuned 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_session registry to leak.
  • The connection is checked out lazily on first query and returned by async with ... as session on exit, including on exception, which is what prevents the idle in transaction leak.
  • Never pass session into a background task that outlives the request. Background work must open its own session from AsyncSessionLocal, because the request’s session closes when the response is sent.
  • Never run asyncio.gather with 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?
Because its correctness depends on a 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?
You can, but you should not. An 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?
It changes when ORM attributes are reloaded, not connection safety. With 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?
Size them to peak concurrent in-flight database operations, not total request rate, because async lets many requests await I/O without holding a connection. Start with 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.