Async SQLAlchemy: asyncpg, Alembic, and Production Patterns
Master async SQLAlchemy with asyncpg and Alembic.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Basic knowledge of Python and asyncio
- ✓Familiarity with SQLAlchemy ORM basics
- ✓PostgreSQL database installed and running
- ✓Python 3.7+
- Use async SQLAlchemy with asyncpg for non-blocking database access in async apps.
- Configure async engine with
create_async_engineand useAsyncSessionfor transactions. - Alembic supports async migrations via
run_async()in env.py. - Production patterns include connection pooling, retry logic, and proper session management.
- Avoid mixing sync and async code; use
asyncio.gatherfor concurrent queries.
Imagine a restaurant where orders come in continuously. A sync waiter handles one order at a time, blocking others. An async waiter can take multiple orders simultaneously, juggling tasks without waiting. Async SQLAlchemy with asyncpg is like having that async waiter for your database—allowing your app to handle many database requests concurrently without slowing down.
In modern web development, asynchronous programming is essential for building high-performance applications that can handle thousands of concurrent connections. Python's asyncio ecosystem, combined with async database drivers like asyncpg, enables non-blocking database operations. SQLAlchemy, the leading ORM for Python, now offers full async support, allowing you to use the same powerful ORM patterns in async contexts.
This tutorial dives deep into async SQLAlchemy with asyncpg, covering setup, query patterns, Alembic migrations, and production-ready practices. You'll learn how to configure an async engine, manage sessions, handle transactions, and avoid common pitfalls. We'll also explore real-world scenarios like connection pooling, retry logic, and debugging async database issues.
By the end, you'll be equipped to build scalable async applications that interact with PostgreSQL efficiently, using SQLAlchemy's familiar API in a non-blocking way. Whether you're building a FastAPI service, an aiohttp app, or any asyncio-based system, these patterns will help you write robust, maintainable database code.
Setting Up Async SQLAlchemy with asyncpg
To get started, install the required packages: pip install sqlalchemy[asyncio] asyncpg. The async engine is created using create_async_engine instead of the sync create_engine. This engine uses asyncpg under the hood to communicate with PostgreSQL asynchronously.
Here's a basic setup:
```python from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/dbname"
engine = create_async_engine(DATABASE_URL, echo=True, pool_size=5, max_overflow=10)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) ```
pool_size: Number of connections to maintain in the pool.max_overflow: Maximum number of connections that can be created beyond pool_size.echo: Logs all SQL statements (useful for debugging).expire_on_commit=False: Prevents objects from being expired after commit, which can avoid lazy loads in async contexts.
Always use async with for sessions to ensure proper cleanup:
``python async with ``async_session() as session: result = await session.execute(select(User).where(User.id == 1)) user = result.scalar_one()
pool_pre_ping=True to verify connections before use, preventing stale connections from causing errors.create_async_engine with asyncpg URL and AsyncSession for non-blocking database access.Defining Models and Running Queries
Models are defined similarly to sync SQLAlchemy, using declarative_base. However, all database operations must be awaited. Use from SQLAlchemy's core to build queries.select()
Example of a simple query:
```python from sqlalchemy import select
async def get_users(): async with async_session() as session: stmt = select(User).where(User.name.like('A%')) result = await session.execute(stmt) users = result.scalars().all() return users ```
For relationships, use selectinload to eagerly load related objects and avoid N+1 queries:
```python from sqlalchemy.orm import selectinload
stmt = select(User).options(selectinload(User.posts)) result = await session.execute(stmt) ```
Async SQLAlchemy also supports raw SQL via :text()
```python from sqlalchemy import text
result = await session.execute(text("SELECT * FROM users WHERE id = :id"), {"id": 1}) ```
Remember that all operations that touch the database must be awaited, including , commit(), rollback(), and flush().refresh()
await session.refresh(user) after commit to get database-generated values like auto-increment IDs.selectinload for eager loading to prevent N+1 queries.Transactions and Session Management
Async SQLAlchemy provides two ways to manage transactions: automatic commit on context exit and explicit transaction blocks.
Automatic commit: When using async with , the session will automatically commit if no exception occurs, otherwise it rolls back. This is the recommended pattern for simple operations.async_session() as session:
Explicit transaction: Use async with to control the transaction boundaries manually. This is useful when you need to perform multiple operations in a single transaction:session.begin():
``python async with ``async_session() as session: async with session.begin(): user = User(name='Alice') session.add(user) # other operations # transaction is committed here
You can also use await and session.commit()await explicitly, but be careful to always close the session.session.rollback()
Nested transactions are supported via savepoints:
``python async with ``session.begin_nested(): # this is a savepoint session.add(some_object)
In production, always handle exceptions and ensure sessions are closed. Use try/finally or context managers.
create_async_engine(..., connect_args={"statement_timeout": 30000}) (30 seconds).async with session.begin() for explicit transaction control. Always ensure sessions are closed.Alembic Migrations for Async Databases
Alembic supports async migrations with a few configuration changes. First, install alembic: pip install alembic. Then initialize: alembic init alembic.
Modify alembic/env.py to use async engine:
```python from sqlalchemy.ext.asyncio import create_async_engine from alembic import context
async def run_migrations_online(): connectable = create_async_engine(DATABASE_URL) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) await connectable.dispose()
def do_run_migrations(connection): context.configure(connection=connection, target_metadata=Base.metadata) with context.begin_transaction(): context.run_migrations()
def run_migrations_online(): asyncio.run(run_migrations_online()) ```
Note: The method is available in newer Alembic versions. Alternatively, use run_async() as shown.asyncio.run()
For offline mode, you can generate SQL scripts synchronously.
When creating migrations, use alembic revision --autogenerate -m "message". The autogenerate will detect model changes and generate the migration script. However, be cautious with async-specific features like async def in migration scripts—avoid them unless necessary.
alembic upgrade --sql to generate SQL for review before applying.asyncio.run() or using run_async().Connection Pooling and Performance Tuning
Async SQLAlchemy uses a connection pool managed by asyncpg. Proper configuration is critical for performance.
pool_size: Number of persistent connections (default 5).max_overflow: Maximum temporary connections beyond pool_size (default 10).pool_timeout: Seconds to wait for a connection from the pool (default 30).pool_recycle: Seconds after which a connection is recycled (default -1, no recycle).pool_pre_ping: If True, verifies connections before use (recommended).
Example: ``python engine = create_async_engine( DATABASE_URL, pool_size=10, max_overflow=20, pool_timeout=60, pool_recycle=3600, pool_pre_ping=True ) ``
For high-concurrency apps, consider using a larger pool size. Monitor pool usage with: ``python ``engine.pool.status() # returns a dict with pool info
Query optimization: Use selectinload for relationships, avoid lazy loading in async contexts. Use limit and offset for pagination. Use execution_options to set statement timeout per query: ``python result = await session.execute(stmt.execution_options(statement_timeout=5000)) ``
Batch operations: Use for bulk inserts: ``insert().returning()python from sqlalchemy import insert stmt = insert(User).returning(User.id, User.name) result = await session.execute(stmt, [{"name": "Alice"}, {"name": "Bob"}]) ``
pool_recycle to less than the database's wait_timeout to prevent connection drops. For AWS RDS, 3600 seconds is common.pool_pre_ping and monitor pool status.Error Handling and Retry Logic
In production, transient errors like connection timeouts, deadlocks, and serialization failures are common. Implement retry logic with exponential backoff.
Use tenacity library for robust retries:
```python from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type from sqlalchemy.exc import OperationalError, DeadlockError
@retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type((OperationalError, DeadlockError)) ) async def execute_with_retry(session, stmt): return await session.execute(stmt) ```
For transaction retries, you need to restart the entire transaction:
```python from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10)) async def transfer_with_retry(sender_id, receiver_id, amount): async with async_session() as session: async with session.begin(): sender = await session.get(User, sender_id) receiver = await session.get(User, receiver_id) sender.balance -= amount receiver.balance += amount ```
Note: The retry decorator will re-execute the entire function on failure, creating a new session.
OperationalError: Connection issues, timeouts.DeadlockError: Deadlock detected.IntegrityError: Constraint violations (usually not retryable).
Always log exceptions for debugging.
Testing Async Database Code
Testing async database code requires async test runners like pytest-asyncio. Install: pip install pytest-asyncio.
Use fixtures to create a test database and session:
```python import pytest from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker
@pytest.fixture async def engine(): engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/testdb") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await engine.dispose()
@pytest.fixture async def session(engine): async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with async_session() as session: yield session
@pytest.mark.asyncio async def test_create_user(session): user = User(name="Test") session.add(user) await session.commit() result = await session.execute(select(User).where(User.name == "Test")) assert result.scalar_one().name == "Test" ```
Use pytest.mark.asyncio decorator for async tests. For mocking, use unittest.mock with async mocks.
Isolation: Use separate test database or transactions that roll back after each test. The fixture above creates tables before and drops after.
--asyncio-mode=auto to speed up test suites.Production Patterns and Best Practices
Deploying async SQLAlchemy in production requires attention to several patterns:
1. Session per request: In web frameworks, create a session per request and close it after the response. Use dependency injection (e.g., FastAPI's Depends).
2. Use connection pooling wisely: Set pool size based on your database's max connections and application concurrency. Monitor with SELECT * FROM pg_stat_activity.
3. Graceful shutdown: Dispose the engine on app shutdown to close all connections: ``python async def ``shutdown(): await engine.dispose()
4. Health checks: Implement a health check endpoint that runs a simple query: ``python async def ``health_check(): async with async_session() as session: await session.execute(select(1)) return {"status": "ok"}
5. Use environment variables for database URLs, never hardcode.
6. Logging: Enable SQLAlchemy logging in development, but disable in production to avoid performance overhead. Use structured logging.
7. Avoid global sessions: Never share sessions across requests or tasks. Each coroutine should have its own session.
8. Use asyncio.gather for concurrent queries: When you need multiple independent queries, run them concurrently: ``python async def get_dashboard_data(user_id): async with ``async_session() as session: user_task = session.get(User, user_id) posts_task = session.execute(select(Post).where(Post.user_id == user_id)) user, posts = await asyncio.gather(user_task, posts_task) return user, posts.scalars().all()
pool_size to the number of concurrent requests your app handles, plus a small buffer. Monitor with SELECT count(*) FROM pg_stat_activity WHERE state = 'active'.The Silent Connection Pool Exhaustion
async with AsyncSession(engine) as session: or explicitly closed with await session.close(). Added monitoring for pool usage.- Always use async context managers for sessions to guarantee cleanup.
- Monitor connection pool metrics (size, overflow, wait time) in production.
- Implement retry logic with exponential backoff for transient pool timeouts.
- Set a reasonable pool size and overflow limit based on expected concurrency.
- Use
pool_pre_ping=Trueto detect stale connections.
pool_pre_ping=True.selectinload for eager loading.run_async(); check for long-running transactions; set a statement timeout.SELECT ... FOR UPDATE carefully.SELECT count(*) FROM pg_stat_activity WHERE state = 'idle in transaction';SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction';async with blocks.| File | Command / Code | Purpose |
|---|---|---|
| async_setup.py | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession | Setting Up Async SQLAlchemy with asyncpg |
| async_queries.py | from sqlalchemy import select, text | Defining Models and Running Queries |
| transactions.py | from sqlalchemy.exc import SQLAlchemyError | Transactions and Session Management |
| alembic | from logging.config import fileConfig | Alembic Migrations for Async Databases |
| pool_config.py | from sqlalchemy.ext.asyncio import create_async_engine | Connection Pooling and Performance Tuning |
| retry_logic.py | from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_excep... | Error Handling and Retry Logic |
| test_async_db.py | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession | Testing Async Database Code |
| production_patterns.py | from sqlalchemy import select | Production Patterns and Best Practices |
Key takeaways
create_async_engine with asyncpg for non-blocking database access.async with to ensure proper cleanup.asyncio.run() or run_async().selectinload to avoid N+1 queries and asyncio.gather for concurrent operations.Interview Questions on This Topic
How do you configure an async SQLAlchemy engine with asyncpg?
create_async_engine with a URL like postgresql+asyncpg://user:pass@host/db. Set pool_size, max_overflow, and other parameters. Then create an AsyncSession class using sessionmaker.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Python Libraries. Mark it forged?
5 min read · try the examples if you haven't