When to Adopt SQLAlchemy’s Async API: A Practical Guide
SQLAlchemy 2.0’s async engine can cut latency for high‑concurrency workloads, but it adds complexity. This blog walks through the problem, setup, a worked example, trade‑offs, and actionable take‑aways for deciding whether to go async.
13 Sept 2026, 17:11 UTC

Problem: Blocking DB Calls in an Async Service
Many modern Python services use asyncio to serve thousands of concurrent requests. If those services hit a relational database via a traditional, blocking SQLAlchemy engine, each query stalls the event loop. Even if the database is fast, the thread that runs the loop cannot do other work while waiting for the round‑trip to the DB.
Typical symptoms:
- High request latency spikes when many users hit the same endpoint.
- CPU usage stays low but throughput drops under load.
- Other async tasks (e.g., WebSocket ping, background jobs) are delayed.
Thesis: Async SQLAlchemy Can Reduce Latency, But Only When the I/O Is the Bottleneck
SQLAlchemy 2.0 introduces a fully asynchronous API that delegates all I/O to an async driver (e.g., asyncpg for PostgreSQL). Benchmarks show that for workloads with hundreds of concurrent queries, async sessions can cut overall latency by 30–50% compared to blocking sessions. However, if the database itself is saturated or the query is CPU‑heavy, the gain disappears. Moreover, the async path adds a learning curve and requires careful session management.
1. Setting Up an Async Engine
Prerequisites
- Python 3.11+ (async support is more mature).
- SQLAlchemy 2.0+.
- Async driver for your DB:
- PostgreSQL:
asyncpg - MySQL:
aiomysql - SQLite:
aiosqlite(experimental). - Optional:
pytest‑asynciofor testing.
Installation
# pip install SQLAlchemy==2.0.0 asyncpg
Replace asyncpg with the driver that matches your database. Verify the driver is importable: python -c "import asyncpg".
Creating the Async Engine
from sqlalchemy.ext.asyncio import create_async_engine
# PostgreSQL DSN: "postgresql+asyncpg://user:pass@host/db"
async_engine = create_async_engine(
"postgresql+asyncpg://user:pass@localhost/mydb",
echo=True, # optional: log SQL
)
The DSN scheme postgresql+asyncpg tells SQLAlchemy to use the async driver. The engine is thread‑safe but should be created once per process.
Async Session Factory
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
async_session = async_sessionmaker(
async_engine, expire_on_commit=False
)
Use async_sessionmaker instead of the sync sessionmaker. The expire_on_commit flag mirrors the sync default; set it to False if you rely on stale data across commits.
2. A Worked Example: Querying Users
Assume a simple User ORM mapped to a users table.
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String(50))
Async Query Function
import asyncio
from sqlalchemy import select
async def get_user_names(session_factory):
async with session_factory() as session:
result = await session.execute(select(User.name))
names = result.scalars().all()
return names
# Run the async function in an event loop
if __name__ == "__main__":
asyncio.run(get_user_names(async_session))
Key points:
- All DB calls are awaited:
await session.execute. - Use
async withto automatically commit or rollback on exit. - Result handling uses
scalars()to fetch single column values.
3. Trade‑offs and Limitations
Complexity
- Every call that interacts with the DB must be awaited; forgetting
awaitraises aRuntimeError. - Mixing sync and async sessions in the same thread can leak connections if the sync pool is not closed.
- Some extensions (e.g., certain Alembic migration scripts) assume a synchronous engine; they may need separate steps.
Driver and Compatibility
- Async drivers are separate packages; missing one results in
ImportErrorat runtime. - SQLAlchemy 2.0 introduces breaking changes (e.g.,
declarative_basenow requires__init__arguments). - SQLite async support is experimental; use with caution in production.
Performance Reality
- Async shines when the event loop is blocked by I/O; if the database is the real bottleneck, latency improvements plateau.
- Connection pooling is still synchronous under the hood; high connection churn can still cause contention.
- Testing async code requires an async test runner; otherwise tests may hang.
4. Actionable Take‑aways
- If your service already uses
asyncioand you notice request latency spikes under load, consider prototyping an async SQLAlchemy path. - Start by creating a separate async engine and sessionmaker; keep the sync engine for legacy code to avoid a full rewrite.
- Run a simple benchmark: spawn 200 concurrent tasks that each execute a lightweight query. Compare total elapsed time between sync and async sessions.
- Use
async with async_engine.begin() as conn:for schema migrations that need to run async code; otherwise, keep Alembic migrations on the sync engine. - Document all async session boundaries in your codebase to prevent accidental leaks.
- Plan for future maintenance: async drivers may receive security updates separately from SQLAlchemy; keep them up to date.
In short, SQLAlchemy’s async API is a powerful tool for high‑concurrency Python services, but it is not a silver bullet. Measure your workload, understand the added complexity, and adopt the async path incrementally to keep your codebase maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.