Async Python Patterns

Python’s asyncio provides cooperative concurrency for I/O-bound workloads. This guide covers the mental model, common patterns, and pitfalls that distinguish senior Python engineers — especially in FastAPI / SQLAlchemy / data pipeline contexts.


Quick Navigation: “I need to…”

I need to… Pattern Section
Run multiple I/O calls concurrently gather() / TaskGroup 1
Build a REST API with async DB FastAPI + async SQLAlchemy 2
Process a queue of work concurrently Worker pool with asyncio.Queue 3
Call blocking code from async context run_in_executor 4
Avoid N+1 queries Eager loading / batch fetch 5
Test async code pytest-asyncio patterns 6
Understand when asyncio helps vs not Concurrency vs parallelism 7
Handle timeouts and cancellation asyncio.timeout / shield 8

The Mental Model

asyncio is cooperative multitasking on a single thread.

Thread 1:  [task A runs] → await (I/O wait) → [task B runs] → await → [task A resumes]
                            ↑
                  event loop switches here

Key insight: "await" is the ONLY point where another coroutine can run.
If you block without await (time.sleep, requests.get, heavy CPU), you freeze the whole loop.

asyncio vs threading vs multiprocessing

  asyncio threading multiprocessing
Parallelism No (1 thread) Limited (GIL) Yes (separate processes)
Best for I/O-bound (DB, HTTP, file) Legacy blocking libs CPU-bound (math, ML)
Overhead Very low Medium (OS threads) High (process spawn)
Shared state Easy (same thread) Hard (race conditions) Hard (IPC needed)
Example FastAPI, async DB driver requests + ThreadPoolExecutor NumPy, pandas, image processing

1. Concurrent Execution

gather() — run all, wait for all

import asyncio
import aiohttp

async def fetch(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url) as resp:
        return await resp.json()

async def fetch_all(urls: list[str]) -> list[dict]:
    async with aiohttp.ClientSession() as session:
        # All requests fire simultaneously; total time ≈ slowest single request
        results = await asyncio.gather(
            *[fetch(session, url) for url in urls],
            return_exceptions=True,   # don't cancel all on single failure
        )
    return [r for r in results if not isinstance(r, Exception)]

TaskGroup — structured concurrency (Python 3.11+)

async def fetch_all_safe(urls: list[str]) -> list[dict]:
    results = []
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(url)) for url in urls]
    # If ANY task raises, TaskGroup cancels siblings and re-raises
    return [t.result() for t in tasks]

gather vs TaskGroup:

  gather(return_exceptions=True) TaskGroup
On error Continues remaining tasks Cancels all siblings
Use when Partial results are useful All-or-nothing semantics
Error handling Check each result Single except block

Semaphore — limit concurrency

async def fetch_with_limit(urls: list[str], max_concurrent: int = 10) -> list[dict]:
    sem = asyncio.Semaphore(max_concurrent)

    async def bounded_fetch(url: str) -> dict:
        async with sem:             # blocks if max_concurrent tasks are already running
            return await fetch(url)

    return await asyncio.gather(*[bounded_fetch(url) for url in urls])

2. FastAPI + Async SQLAlchemy

Setup

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

# Use asyncpg driver for PostgreSQL
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/mydb",
    pool_size=20,
    max_overflow=10,
    echo=False,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id:    Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True)
    name:  Mapped[str]

Dependency injection pattern

from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy import select

app = FastAPI()

async def get_db() -> AsyncSession:
    """Yield a session per request; auto-close on exit."""
    async with AsyncSessionLocal() as session:
        yield session
        # No explicit commit — route handlers commit explicitly when needed

@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.post("/users")
async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db)):
    user = User(email=data.email, name=data.name)
    db.add(user)
    await db.commit()
    await db.refresh(user)  # reload to get server-generated fields (id, created_at)
    return user

Upsert

from sqlalchemy.dialects.postgresql import insert as pg_insert

async def upsert_user(db: AsyncSession, data: dict) -> User:
    stmt = (
        pg_insert(User)
        .values(**data)
        .on_conflict_do_update(
            index_elements=["email"],
            set_={"name": data["name"], "updated_at": func.now()},
        )
        .returning(User)
    )
    result = await db.execute(stmt)
    await db.commit()
    return result.scalar_one()

3. Worker Pool Pattern

Use when you have many jobs to process but want to cap concurrency (rate limiting, DB connection limits).

import asyncio
from asyncio import Queue

async def producer(queue: Queue, items: list):
    for item in items:
        await queue.put(item)
    # Signal workers to stop
    for _ in range(NUM_WORKERS):
        await queue.put(None)

async def worker(worker_id: int, queue: Queue, results: list):
    while True:
        item = await queue.get()
        if item is None:
            break
        try:
            result = await process(item)
            results.append(result)
        except Exception as e:
            print(f"Worker {worker_id} failed on {item}: {e}")
        finally:
            queue.task_done()

async def run_pool(items: list, num_workers: int = 5) -> list:
    queue: Queue = asyncio.Queue(maxsize=num_workers * 2)
    results = []

    workers = [asyncio.create_task(worker(i, queue, results)) for i in range(num_workers)]
    await producer(queue, items)
    await asyncio.gather(*workers)

    return results

4. Mixing Sync and Async

Calling blocking code from async context

import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

executor = ThreadPoolExecutor(max_workers=10)

async def run_blocking_io(func, *args):
    """Run a blocking I/O function without freezing the event loop."""
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(executor, func, *args)

async def example():
    # requests.get is blocking — wrap it
    response = await run_blocking_io(requests.get, "https://api.example.com/data")

    # pandas is CPU-heavy — use ProcessPoolExecutor
    cpu_executor = ProcessPoolExecutor(max_workers=4)
    result = await loop.run_in_executor(cpu_executor, compute_heavy, dataframe)

Common mistake: blocking the event loop

# ❌ BAD — freezes the event loop for the entire sleep duration
async def bad():
    time.sleep(5)           # blocks the thread → no other coroutine can run

# ✅ GOOD — yields control back to event loop
async def good():
    await asyncio.sleep(5)  # suspends only this coroutine

# ❌ BAD — synchronous HTTP call inside async function
async def bad_fetch(url):
    return requests.get(url).json()   # blocks thread

# ✅ GOOD — async HTTP with aiohttp
async def good_fetch(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.json()

5. Avoiding N+1 Queries

from sqlalchemy.orm import selectinload, joinedload

# ❌ N+1: 1 query for users, then 1 query PER user for orders
users = (await db.execute(select(User))).scalars().all()
for user in users:
    orders = await db.execute(select(Order).where(Order.user_id == user.id))

# ✅ selectinload: 2 queries total (1 for users, 1 for all orders WHERE user_id IN (...))
stmt = select(User).options(selectinload(User.orders))
users = (await db.execute(stmt)).scalars().all()
# user.orders is now pre-loaded — no extra queries

# ✅ joinedload: 1 query with JOIN (better for to-one relationships)
stmt = select(Order).options(joinedload(Order.user))
orders = (await db.execute(stmt)).scalars().all()
# order.user is pre-loaded

# ✅ Batch fetch: manual IN query
user_ids = [1, 2, 3, 4, 5]
orders = (
    await db.execute(select(Order).where(Order.user_id.in_(user_ids)))
).scalars().all()

6. Testing Async Code

Setup

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"   # all async tests auto-decorated
# conftest.py
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

TEST_DB_URL = "postgresql+asyncpg://user:pass@localhost/test_db"

@pytest.fixture(scope="session")
async def engine():
    eng = create_async_engine(TEST_DB_URL)
    async with eng.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield eng
    async with eng.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await eng.dispose()

@pytest.fixture
async def db(engine):
    """Each test gets its own transaction, rolled back on exit."""
    async with engine.begin() as conn:
        session = AsyncSession(bind=conn)
        yield session
        await session.rollback()

@pytest.fixture
async def client(db):
    app.dependency_overrides[get_db] = lambda: db
    async with AsyncClient(app=app, base_url="http://test") as c:
        yield c
    app.dependency_overrides.clear()

Test patterns

async def test_create_user(client, db):
    response = await client.post("/users", json={"email": "a@b.com", "name": "Alice"})
    assert response.status_code == 200
    assert response.json()["email"] == "a@b.com"

    # Verify DB state directly
    result = await db.execute(select(User).where(User.email == "a@b.com"))
    user = result.scalar_one()
    assert user.name == "Alice"

@pytest.mark.parametrize("email,status_code", [
    ("valid@example.com", 200),
    ("not-an-email", 422),
    ("", 422),
])
async def test_email_validation(client, email, status_code):
    response = await client.post("/users", json={"email": email, "name": "Test"})
    assert response.status_code == status_code

7. Concurrency Model — When to Use What

Problem type          Best tool
─────────────────────────────────────────────────────
HTTP API server       FastAPI (asyncio)
DB queries            asyncpg / async SQLAlchemy
File I/O (many files) asyncio.gather + aiofiles
CPU computation       multiprocessing / ProcessPoolExecutor
Legacy sync library   ThreadPoolExecutor + run_in_executor
Background tasks      asyncio.create_task OR Celery (cross-process)
Scheduled jobs        Airflow (not asyncio)

Connection pool sizing

# Rule of thumb for async DB pool:
#   pool_size = num_workers × avg_concurrent_queries_per_worker
# For a FastAPI service with 4 uvicorn workers, each handling 50 concurrent requests,
# each using 1 DB query at a time:
#   pool_size = 4 × 50 = 200  (but PostgreSQL default max_connections = 100!)
# Solution: use PgBouncer connection pooler in front of PostgreSQL

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,           # connections kept open
    max_overflow=10,        # extra connections allowed on burst (closed when done)
    pool_timeout=30,        # raise error if no connection available after 30s
    pool_recycle=1800,      # recycle connections every 30min (avoids stale conn)
)

8. Timeouts and Cancellation

import asyncio

# asyncio.timeout (Python 3.11+) — raises TimeoutError
async def fetch_with_timeout(url: str) -> dict:
    async with asyncio.timeout(5.0):    # 5 second deadline
        return await fetch(url)

# asyncio.wait_for — older equivalent
async def fetch_with_timeout_legacy(url: str) -> dict:
    return await asyncio.wait_for(fetch(url), timeout=5.0)

# Shield: protect a coroutine from cancellation (e.g., DB write)
async def process_and_save(data):
    result = transform(data)
    # Even if this coroutine is cancelled, the DB write completes
    await asyncio.shield(db.commit())

# Graceful shutdown pattern
async def main():
    tasks = [asyncio.create_task(worker(i)) for i in range(10)]
    try:
        await asyncio.gather(*tasks)
    except asyncio.CancelledError:
        for task in tasks:
            task.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
        raise

Common Pitfalls

Pitfall Symptom Fix
Blocking call in async def Event loop stalls; all requests queue up Use run_in_executor or async equivalent
Missing await Returns coroutine object instead of result Always await coroutine calls
Sharing session across requests MissingGreenlet / state corruption One session per request via Depends
expire_on_commit=True (default) DetachedInstanceError after commit Set expire_on_commit=False in sessionmaker
Creating tasks without reference Task garbage-collected mid-run Store reference: tasks.add(asyncio.create_task(...))
asyncio.run() inside async context RuntimeError: This event loop is already running Use await instead; use nest_asyncio for notebooks

Quick Reference

# Run async function from sync context
asyncio.run(main())

# Create background task (fire and forget)
task = asyncio.create_task(background_job())
background_tasks = set()
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)  # prevent GC

# Async context manager
class AsyncResource:
    async def __aenter__(self): ...
    async def __aexit__(self, *args): ...

async with AsyncResource() as r: ...

# Async iterator
class AsyncStream:
    def __aiter__(self): return self
    async def __anext__(self): ...

async for item in AsyncStream(): ...

# Async generator
async def generate():
    for i in range(10):
        await asyncio.sleep(0.1)
        yield i

async for value in generate(): ...