Async Database Sessions in FastAPI
Async database sessions are how a FastAPI application reaches a database without stalling its event loop: one engine owning a connection pool for the whole process, and one short-lived session per request drawn from it through a yield dependency.
This topic is part of Async, Background Tasks and Observability, and it is where async correctness most often succeeds or fails in practice — a synchronous database driver is the single most common way to block a worker. Structurally it is an application of the yield pattern from Dependency Injection Strategies, with a resource whose mismanagement is unusually expensive.
The recurring theme across everything below is scope. Almost every bug in this area is a lifetime mismatch: an object that lives longer than it should (a shared session, a leaked connection, a transaction held over a network call), or shorter than it should (an engine rebuilt per request, a session closed under a streaming response).
Prerequisites
- SQLAlchemy 2.0 with an async driver — asyncpg for PostgreSQL, aiosqlite for SQLite.
- A FastAPI app using
lifespanfor startup state andDependsfor injection. - Comfort with the idea that
awaiton a query is what keeps the loop free. If that is not yet solid, read Async Correctness and Concurrency first.
Transcripts on this page were produced against SQLAlchemy 2.0.51 with aiosqlite. The session and pool behaviour shown is SQLAlchemy's own and is not driver-specific; where PostgreSQL differs it is called out.
Core Mechanics: The Engine Is Not the Session
The most common structural mistake is treating these as one thing. They have almost nothing in common.
The engine owns the connection pool. Building one opens sockets and starts housekeeping, so it is expensive and belongs to the process. It is thread-safe, coroutine-safe, and meant to be shared by everything.
The session is a unit of work: an identity map, a set of pending changes, and — once it touches the database — one borrowed connection. It is cheap, is emphatically not safe to share across concurrent tasks, and must be closed.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@asynccontextmanager
async def lifespan(app: FastAPI):
engine = create_async_engine(settings.database_url, pool_size=10, max_overflow=5)
# expire_on_commit=False is required rather than preferred under async.
app.state.session_factory = async_sessionmaker(engine, expire_on_commit=False)
app.state.engine = engine
yield
await engine.dispose()
Creating the engine inside a request handler — or worse, inside a dependency — creates a pool per request and will exhaust the database's connection limit within seconds of real traffic.
expire_on_commit=False deserves its reputation as mandatory boilerplate. The default marks attributes stale at commit, and the subsequent lazy refresh cannot await, so it raises MissingGreenlet during response serialization — a traceback that points at Pydantic rather than at your query. The full explanation and the transcript are in Async SQLAlchemy Session per Request.
Production Implementation: The Session Dependency
One dependency does the whole job — acquire, hand over, commit or roll back, close:
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
"""One session per request; the transaction boundary lives here and nowhere else."""
async with request.app.state.session_factory() as session:
try:
yield session
except Exception:
await session.rollback()
raise
else:
await session.commit()
SessionDep = Annotated[AsyncSession, Depends(get_session)]
Handlers then never mention transactions:
@router.post("/orders")
async def create_order(payload: OrderIn, session: SessionDep) -> dict:
order = Order(**payload.model_dump())
session.add(order)
await session.flush() # emits the INSERT and populates order.id
return {"id": order.id}
flush() rather than commit() is the habit worth building. Flush sends the SQL, fires constraints and populates generated keys, all while staying inside the transaction the dependency owns. A commit() in a handler ends that transaction early and turns every subsequent failure into a durable partial write — the subject of Transaction Management and Rollback.
Two properties of this arrangement are worth verifying rather than assuming, and both are:
$ GET /probe/concurrent-requests-get-distinct-sessions
200 OK
{
"requests": 10,
"distinct_session_objects": 10
}
Ten overlapping requests, ten separate session objects. There is no shared mutable state between concurrent requests, which is what makes the pattern safe under load. Within any single request the opposite holds: FastAPI caches the dependency, so the handler and every nested dependency share one session and one transaction.
Async and Performance Notes
Every database call must be awaited. A synchronous driver blocks the loop for every request on the worker, not just the one running the query.
Pool size is your per-worker concurrency ceiling for database work. pool_size + max_overflow is the most simultaneous in-flight queries one worker can hold. Beyond that, requests queue, and once they queue for longer than pool_timeout they fail.
Hold time matters more than pool size. Capacity is throughput divided by hold time, so halving how long each request holds a connection doubles effective capacity for free. Here is what that looks like at the edge — two requests through a two-slot pool, each holding a connection for one second:
$ GET /probe/within-the-ceiling
200 OK
{
"concurrent_requests": 2,
"status_counts": {
"200": 2
},
"waves_of_the_pool": 1,
"capacity": 2,
"one_query_takes_s": 1.0
}
Exactly at capacity, everything overlaps: two concurrent requests completed in a single wave of the pool rather than one after the other. Add a third and it waits. The behaviour past that boundary, including the exact error, is in Fixing asyncpg Connection Pool Exhaustion.
Keep network calls out of transactions. An awaited HTTP call inside a transaction holds a database connection for its full duration, doing nothing with it.
expire_on_commit=False serves a snapshot. If a trigger or server-side default changes the row during commit, your in-memory object will not know. Refresh explicitly when that matters.
Testing Strategy
The goal is a suite that runs real SQL against a real database and leaves no residue. The pattern that achieves it has three nested fixtures:
- A session-scoped engine that creates the schema once.
- A function-scoped transaction that the fixture opens and rolls back at the end of every test.
- A client fixture that overrides
get_sessionto hand the app that transaction-bound session, then clears the override.
@pytest.fixture
async def client(db_session):
async def override() -> AsyncIterator[AsyncSession]:
yield db_session
app.dependency_overrides[get_session] = override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
Isolation comes from the outer transaction being rolled back, not from truncating tables — which is both faster and stricter, since it rolls back sequences too. The setting that makes it survive the app's own commit() calls is join_transaction_mode="create_savepoint", and the complete chain with a passing transcript is in Testing with Async Database Fixtures.
Two assertions earn their place in any database suite. First, that the override is actually installed — a broken override is invisible, because the tests still pass, just against the wrong database. Second, that connections return to the pool:
async def test_connections_return_to_the_pool(app, client):
pool = app.state.engine.pool
baseline = pool.checkedout()
await asyncio.gather(*[client.get("/orders/1") for _ in range(20)])
assert pool.checkedout() == baseline
Use httpx.AsyncClient, not TestClient, for anything involving async fixtures: the fixture and the application code must share one event loop, and TestClient runs the app on a portal thread with a loop of its own. See TestClient vs httpx AsyncClient.
Failure Modes and Diagnosis
MissingGreenlet: greenlet_spawn has not been called — lazy I/O attempted where it cannot await. Two usual causes: expire_on_commit left at its default, or a relationship accessed after the session closed. Fix with expire_on_commit=False and eager loading via selectinload.
QueuePool limit of size N overflow M reached — the pool is empty and this request waited out pool_timeout. Read the numbers in the message and compare them to your engine configuration; then determine whether it is a leak or a shortfall.
checkedout() never returns to zero at idle — a leak, almost always a dependency that returns a session instead of yielding one. A leak produces no errors where it happens; the failure lands on some unrelated request later.
Latency rises while the database is idle — pool saturation, not database slowness. Requests are waiting for a connection, and the database has nothing to do because nothing is reaching it.
"attached to a different loop" in tests — an async fixture and its consumer are on different event loops. Align asyncio_default_fixture_loop_scope with the fixture's scope.
A write silently does not persist despite a 200 — a background task borrowed the request's session and failed, causing the dependency to roll back the request's own work after the response had gone. Give background work its own session.
Rows from one test appearing in another — the test transaction is committing rather than rolling back, or the app opened its own session bypassing the override.
Choosing a Session Strategy
| Context | Session source | Transaction boundary | Why |
|---|---|---|---|
| Ordinary request handler | SessionDep | The yield dependency | One unit of work per request, cleanup guaranteed |
| Multi-step unit of work | SessionDep | Explicit session.begin() blocks | Finer control without leaving request scope |
| Partial rollback within a request | SessionDep | begin_nested() savepoint | Undo one step, keep the rest |
| Background task | New session from the factory | The task itself | Failures must not touch the request's transaction |
| Batch or migration job | New session, chunked | Per chunk | One transaction over a million rows is a lock and a rollback risk |
| Streaming response | Session managed by hand | Explicit | The dependency closes before the body finishes generating |
FAQ
Where should the async engine and session factory be created?
Once at startup, in the lifespan context manager, stored on app.state. The engine owns the connection pool, which is a long-lived process resource. Sessions are then created per request from that shared factory, and the engine is disposed on shutdown.
Why must each request get its own session?
A session holds transaction state, an identity map and a connection, and is not safe to share across concurrent requests. A per-request session provided by a yield dependency isolates each transaction and returns its connection when the request ends.
Why is expire_on_commit=False in every FastAPI example?
Because the default breaks async serialization. With expire_on_commit=True, committing expires loaded attributes, and the next access attempts a lazy refresh that cannot await, raising MissingGreenlet during response serialization rather than at the query.
How should I size the connection pool?
Multiply pool_size plus max_overflow by your worker count, add whatever background workers and migration tooling need, and keep the total safely under the database's max_connections. Size from measured concurrency; an oversized pool starves the database and an undersized one queues requests.
What causes connection pool exhaustion? Either a leak or a capacity shortfall. A leak comes from sessions that are never closed, typically a dependency that returns a session instead of yielding one. A shortfall is genuine over-subscription, and it shows up as latency long before it shows up as errors.
Can a background task use the request's session? No. It appears to work because tasks run before the dependency's teardown, but if the task fails, the dependency rolls back the request's own writes after the client already received a 200. Background work should open its own session from the shared factory.
Related
- Up to the area: Async, Background Tasks and Observability.
- The wiring: Async SQLAlchemy Session per Request covers request scope,
expire_on_commitand the background-task trap. - The boundary: Transaction Management and Rollback on which code is allowed to commit.
- The failure mode: Fixing asyncpg Connection Pool Exhaustion separates leaks from sizing problems.
- The test harness: Testing with Async Database Fixtures builds rollback-per-test end to end.
- The prerequisite: Async Correctness and Concurrency, since a sync driver here blocks the whole worker.