How create_async_engine, AsyncSession and async_sessionmaker fit together
The async ORM stack has three layers, and each owns a different resource. create_async_engine builds the engine from a URL such as postgresql+asyncpg://.... The engine holds the connection pool and the dialect that talks to asyncpg. It is long-lived and safe to share across tasks. It is also expensive to create, because each engine starts its own pool. That is why one engine per process is the default: a second engine means a second pool, which doubles the connections the process can open against the same database.
async_sessionmaker is a factory bound to that engine. You configure it once with options like expire_on_commit, then call it to get a fresh AsyncSession for each unit of work. The session is cheap and stateful, and it is not safe for concurrent use. It tracks loaded objects in its identity map and borrows a connection from the engine's pool only when it first needs to run SQL. Internally, AsyncSession wraps a regular synchronous Session and uses greenlets to bridge its blocking-style code onto asyncio.
Teams adopt this stack when the rest of the service already runs on an event loop, such as async web frameworks, queue consumers or background workers. In those services a blocking driver call would stall every other coroutine.
The bridge hides two I/O paths that the rest of this article examines. The first is pool checkout, which can wait when the pool is exhausted. The second is implicit lazy loading, where reading an unloaded relationship attribute tries to issue SQL that nothing can await.
import asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# One engine per process: it owns the connection pool and the asyncpg dialect.
engine = create_async_engine(
"postgresql+asyncpg://app_user:change-me@db.internal:5432/app",
pool_size=5, # connections kept open in the pool
max_overflow=10, # extra connections allowed under burst load
pool_timeout=30, # seconds a checkout waits before raising
)
# Configure the session factory once; call it per unit of work.
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def handle_request(user_id: int) -> None:
# Fresh session per task: sessions are cheap but not safe to share concurrently.
async with SessionLocal() as session:
# Hidden I/O path 1: the first SQL statement checks out a pooled connection,
# which can wait up to pool_timeout if the pool is exhausted.
result = await session.execute(select(1))
print(user_id, result.scalar_one())
# Hidden I/O path 2: touching an unloaded relationship here would try to
# lazy-load with SQL that cannot be awaited (covered later).
async def main() -> None:
# Many concurrent tasks share one engine, each with its own session.
await asyncio.gather(*(handle_request(i) for i in range(3)))
# Close pooled connections cleanly on shutdown.
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())
A single process-wide async engine owns the asyncpg pool, while async_sessionmaker hands each concurrent task its own short-lived AsyncSession, with comments marking where pool checkout and lazy loading can trigger hidden I/O.
What the connection pool manages on top of asyncpg
The engine wraps asyncpg connections in an asyncio-aware queue pool. On checkout, the session borrows a connection from the pool, or opens a new one if none are idle and the limits allow it. On return, the pool rolls back any open transaction so that leftover state does not leak to the next borrower, then keeps the connection idle. If the pool already holds pool_size idle connections, an overflow connection is closed instead.
| Setting | What it controls | Trade-off |
|---|---|---|
pool_size |
Idle connections kept open | Too high wastes server slots |
max_overflow |
Extra connections allowed beyond pool_size |
Overflow connections are opened and closed, which adds handshake cost |
pool_timeout |
Seconds to wait for a free connection | Too short turns a brief slowdown into checkout errors |
pool_recycle |
Maximum connection age before replacement | Age-based only, so it misses connections killed by firewalls or load balancers |
pool_pre_ping |
Liveness check on every checkout | Adds a round trip to each checkout |
pool_recycle and pool_pre_ping handle different failures. Recycling helps when you know the server or a proxy closes idle connections after a fixed interval. It cannot detect a connection that a NAT device or failover silently dropped. Pre-ping catches those cases, but it adds latency to every checkout.
Do not put a separate asyncpg pool underneath the engine. Two pools stacked this way each enforce their own limits. SQLAlchemy's sizing, invalidation and recycling then act on wrappers rather than on the real sockets. Let one layer own connection lifetime. If a pooler outside the process takes that role, configure the engine with NullPool instead.
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
DB_URL = "postgresql+asyncpg://app_user:change-me@db.internal:5432/appdb"
# SQLAlchemy owns connection lifetime. Do not stack an asyncpg pool underneath.
engine = create_async_engine(
DB_URL,
pool_size=10, # idle connections kept open between requests
max_overflow=5, # burst capacity; overflow connections close on return
pool_timeout=30, # seconds to wait for a free connection before raising
pool_recycle=1800, # replace connections older than 30 min (age-based only)
pool_pre_ping=True, # check liveness on checkout; catches sockets dropped by NAT/failover
)
# Worst case per process: pool_size + max_overflow = 15 server connections.
# Multiply by worker processes when checking against the server's connection limit.
# Alternative: an external pooler outside the process owns connection lifetime.
# NullPool opens a connection on checkout and closes it on return, so
# SQLAlchemy keeps no idle connections of its own.
pooled_engine = create_async_engine(
"postgresql+asyncpg://app_user:change-me@pooler.internal:6432/appdb",
poolclass=NullPool,
)
async def shutdown() -> None:
# Close pooled connections cleanly when the application stops.
await engine.dispose()
await pooled_engine.dispose()
Configures an async engine whose own pool manages asyncpg connections with explicit size, overflow, timeout, recycle and pre-ping settings, and shows NullPool for the case where an external pooler owns connection lifetime.
Scoping sessions: one AsyncSession per request or unit of work
An AsyncSession is a stateful unit of work: it holds an identity map, pending changes and, once it touches the database, a checked-out connection with an open transaction. That state is why it should live for exactly one request or one job, and no longer.
In a web service, the usual shape is a request-scoped dependency that opens a session from async_sessionmaker with async with, yields it to the handler, and lets the context manager close it on exit. For background work, wrap each unit in async with session.begin() so commit or rollback happens at a clear boundary. In both cases the session should be closed by a context manager, not by code you hope runs later.
Do not share one session across concurrent tasks. AsyncSession is not safe for concurrent use. If two coroutines in asyncio.gather await queries on it, both drive the same connection and transaction, and the identity map is mutated from interleaved points. The result is an error about concurrent operations or, worse, silently mixed state. Give each task its own session, created inside the task, and gather those tasks.
Lifecycle hygiene extends past sessions. Await engine.dispose() during application shutdown so pooled connections close cleanly instead of being dropped when the process exits.
Cancellation needs care. A task cancelled mid-query unwinds through its context manager, but the rollback and connection release are themselves awaits that can be interrupted. Until cleanup finishes, or garbage collection reclaims the connection, it stays checked out and counts against the pool.
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
# Placeholder DSN; load real credentials from config, never source code.
engine = create_async_engine(
"postgresql+asyncpg://app:change-me@db.internal:5432/orders",
pool_size=5,
max_overflow=5,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_session():
# Request-scoped dependency: one session per request, closed on exit
# even if the handler raises.
async with SessionLocal() as session:
yield session
async def order_count(customer_id: int) -> int:
# Unit of work: this task owns its session; begin() commits on success
# and rolls back on error, then the outer block returns the connection.
async with SessionLocal() as session:
async with session.begin():
result = await session.execute(
text("SELECT count(*) FROM orders WHERE customer_id = :cid"),
{"cid": customer_id},
)
return result.scalar_one()
async def main() -> None:
try:
# WRONG: gather(query(s), query(s)) with one shared session.
# RIGHT: each task opens its own session inside the task.
counts = await asyncio.gather(*(order_count(c) for c in (101, 102, 103)))
print(counts)
finally:
# Close pooled connections cleanly on shutdown.
await engine.dispose()
asyncio.run(main())
A request-scoped session dependency, a unit-of-work function that opens its own session per task for use with asyncio.gather, and engine disposal on shutdown.
Why lazy loading fails under asyncio and how to load relationships on purpose
Attribute access on an ORM instance is a plain Python descriptor, so user.orders is a synchronous expression with no await in front of it. Under the async extension, SQLAlchemy runs its synchronous internals inside a greenlet and bridges each database call back to the event loop. When you touch an unloaded relationship outside that bridged context, the ORM cannot pause for I/O. It raises MissingGreenlet instead. This is deliberate. A synchronous driver would quietly issue a query at that point, but here the failure is loud and immediate.
The fix is to decide at query time which relationships the code will need and load them in the same await. Use selectinload for collections and joinedload for many-to-one references. For nested paths, chain the options so each level is loaded explicitly, for example orders and then each order's line items.
| Strategy | Best for | SQL emitted | Main cost |
|---|---|---|---|
selectinload |
One-to-many, many-to-many | One extra SELECT ... WHERE key IN (...) per relationship level |
Extra round trip per level; very large IN lists are sent in batches and can still be slow |
joinedload |
Many-to-one, one-to-one | LEFT OUTER JOIN in the main query |
On collections, parent rows repeat once per child, and results must be de-duplicated |
| Chained options | Nested relationships | Combination of the above | Each level adds its own query or join |
Per-query options keep payloads lean because each endpoint loads only what it uses. The cost is that loading decisions end up scattered across call sites. When someone later adds a field to a response, a missing option shows up as a runtime error rather than a review comment.
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from myapp.models import Order, User # placeholder: User.orders -> Order.line_items, Order.shipping_address
async def order_ids_broken(session: AsyncSession, user_id: int) -> list[int]:
user = await session.get(User, user_id)
# user.orders was never loaded. Plain attribute access cannot await,
# so the ORM raises MissingGreenlet instead of issuing a hidden query.
return [o.id for o in user.orders]
async def load_user_for_invoice(session: AsyncSession, user_id: int) -> User:
stmt = (
select(User)
.where(User.id == user_id)
.options(
# Collection: one extra SELECT ... WHERE user_id IN (...)
# Nested collection: one more SELECT for that level
selectinload(User.orders).selectinload(Order.line_items),
# Many-to-one under the same path: LEFT OUTER JOIN on the orders query
selectinload(User.orders).joinedload(Order.shipping_address),
)
)
result = await session.execute(stmt)
# Everything this endpoint reads is loaded inside this await.
return result.scalar_one()
async def users_with_orders_joined(session: AsyncSession) -> list[User]:
# joinedload on a collection repeats each User row once per Order,
# so the result must be de-duplicated with .unique() before reading it.
stmt = select(User).options(joinedload(User.orders))
result = await session.execute(stmt)
return list(result.unique().scalars().all())
Accessing an unloaded relationship raises MissingGreenlet. Chained selectinload and joinedload options load nested collections and many-to-one references in the same await. Using joinedload on a collection returns duplicate parent rows that need .unique() to remove.
Guardrails and escape hatches: raiseload, expire_on_commit and deferred loading
Setting lazy="raise" on a relationship, or applying raiseload("*") as a query option, turns that silent dependency into an immediate error. Any access to an attribute that wasn't eagerly loaded raises instead of attempting I/O. The failure then appears in development and tests, where the stack trace points at the exact access, not as a MissingGreenlet error deep in a request. The cost falls on scripts, admin tools and test fixtures that navigated relationships casually. Each one now needs explicit loader options, or a per-query override such as selectinload() on the paths it touches.
Expired attributes fail the same way. By default, commit() expires every loaded attribute, so reading user.email afterwards triggers a refresh that the async session cannot perform implicitly. Passing expire_on_commit=False to async_sessionmaker keeps the loaded values readable. The trade-off is that those values are a snapshot. Server-side defaults, triggers and concurrent writes will not appear until you reload. Use it when you serialize the object right after commit, not when later logic depends on current database state.
For deliberate lazy access there are three escape hatches:
AsyncAttrslets you writeawait obj.awaitable_attrs.orders. The I/O is explicit, but in a loop it brings back N+1 queries.await session.refresh(obj, attribute_names=[...])reloads named attributes. It suits a single object after a write.run_sync()runs synchronous ORM code through greenlet bridging, so lazy loads work again. That is also its danger: I/O happens without any visibleawait. Keep it for porting legacy sync code, and avoid it in new request paths.
Sizing pools against database limits and when an external pooler helps
Every process that creates an engine gets its own pool. The ceiling you actually impose on the database is:
total connections = processes × (pool_size + max_overflow)
A process here means each worker in each replica. Pools are not shared across workers, even on the same host.
Illustrative estimate (assumed inputs, not measured): 4 replicas × 4 Uvicorn workers = 16 processes. With pool_size=10 and max_overflow=5, the peak is 16 × 15 = 240 connections. Against an assumed max_connections of 200, a traffic burst exhausts the server before your pools are full. This happens before you count migrations, cron jobs or an admin shell. Autoscaling to 6 replicas pushes the peak to 360.
Raising pool sizes rarely fixes slow queries. Each Postgres connection is a backend process with its own memory. Past a certain point, extra concurrent queries mostly compete for CPU, locks and I/O. Size pools for what the database can execute in parallel, and let requests wait on pool_timeout instead of piling onto the server.
| Approach | Where pooling happens | Main trade-off |
|---|---|---|
| SQLAlchemy pool alone | Per process | Simple, but the total grows with every replica and worker |
NullPool + external pooler |
Pooler (session mode) | Central cap; adds a network hop per checkout |
| Transaction-mode pooler (e.g. PgBouncer) | Pooler, per transaction | Highest multiplexing; breaks session-level state |
Transaction mode hands a server connection to a different client after each transaction. This breaks anything that lives on the session. Examples include SET without LOCAL, advisory locks, LISTEN, temporary tables and server-side prepared statements. asyncpg caches prepared statements by default, so you usually need to disable both asyncpg's statement cache and the dialect's prepared statement cache. Check what your pooler version supports, and test under concurrent load before relying on it.
When to use the async ORM, alternatives, and impact on cost and engineering time
Choose the async ORM when a service spends most of its time waiting on I/O and already runs on an event loop, such as an ASGI API that calls the database and other services concurrently. Avoid it in three cases. CPU-bound workers gain nothing from the loop. In mostly synchronous codebases, async spreads to every call site. Models built on implicit lazy loading must have each access path rewritten before they stop raising MissingGreenlet.
| Option | Good fit | Trade-off |
|---|---|---|
Async ORM (AsyncSession) |
Async services with relationship-heavy models | Explicit loader options and strict session scoping |
Sync ORM in a thread pool (asyncio.to_thread) |
Existing sync models inside an async framework | Concurrency capped by thread count; each busy thread holds a connection |
| SQLAlchemy Core on an async engine | Reporting, bulk writes, query-shaped workloads | No identity map or relationship loading |
| asyncpg directly | Hot paths that need minimal overhead | Hand-written SQL and row mapping, no dialect portability |
The async ORM costs more up front. Someone has to choose loader options per query, wire one session per request, and turn on raise-on-lazy-load. That work moves failures from production into tests. N+1 patterns surface as exceptions instead of slow pages, and leaked sessions show up as test failures before they exhaust the pool under real traffic.
Checklist
- One
AsyncSessionper request or task, closed by a context manager. -
lazy="raise"on relationships orraiseload("*")in tests. Use explicitselectinloadorjoinedloadper query. -
expire_on_commit=Falsewherever objects are read after commit. - (pool size + max overflow) × processes × replicas stays below the database connection limit, with headroom for admin and migrations.
- Alerts on pool checkout timeouts and checkout wait time.
- Statement-cache settings for any external pooler verified under concurrent load.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to sqlalchemy async session, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.