Async Patterns & Best Practices
Master async context managers, iterators, and production-ready patterns.
Beyond the Basics
Once you understand async fundamentals, the next step is mastering advanced patterns that make your async code robust, maintainable, and production-ready. This lesson covers async context managers for resource management, async iterators for streaming data, comprehensive error handling strategies, and battle-tested patterns used in real-world systems.
What You'll Master:
- Async Context Managers: Properly manage resources with async with
- Async Iterators & Generators: Stream data efficiently
- Error Handling: Catch and recover from failures gracefully
- Production Patterns: Connection pools, circuit breakers, retries
- Testing Async Code: Strategies for reliable async tests
Async Context Managers
Async context managers ensure proper setup and cleanup of async resources like database connections, file handles, and network connections.
Using async with
import aiohttp
import asyncio
# Async context manager ensures proper cleanup
async def fetch_data(url):
# aiohttp session is an async context manager
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
return data
# Session automatically closed here, even if error occurs
async def main():
result = await fetch_data(
'https://jsonplaceholder.typicode.com/todos/1'
)
print(result)
asyncio.run(main())Expected Output:
{'userId': 1, 'id': 1, 'title': 'delectus aut autem', 'completed': False}async with earns its keep for the same reason finally does: the session is closed on the way out whether the block returned normally or raised. The difference is that __aenter__ and __aexit__ are themselves awaitable, so setup and teardown can do I/O of their own, which a plain with cannot. In a long-running service that is the line between a connection pool that stays healthy and one that leaks a socket per failed request.
Creating Custom Async Context Managers
import asyncio
from contextlib import asynccontextmanager
# Method 1: Using __aenter__ and __aexit__
class AsyncDatabaseConnection:
def __init__(self, host):
self.host = host
self.connection = None
async def __aenter__(self):
"""Called when entering 'async with' block"""
print(f"Connecting to {self.host}...")
await asyncio.sleep(1) # Simulate connection
self.connection = f"Connected to {self.host}"
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Called when exiting 'async with' block"""
print(f"Closing connection to {self.host}...")
await asyncio.sleep(0.5) # Simulate cleanup
self.connection = None
return False # Don't suppress exceptions
# Usage
async def main():
async with AsyncDatabaseConnection("localhost") as db:
print(f"Using: {db.connection}")
# Do work with connection
# Connection automatically closed
# Method 2: Using @asynccontextmanager decorator
@asynccontextmanager
async def database_connection(host):
"""Simpler way to create async context manager"""
# Setup
print(f"Connecting to {host}...")
connection = await connect_to_database(host)
try:
yield connection # Provide resource
finally:
# Cleanup (always runs)
print(f"Closing connection to {host}...")
await connection.close()
# Usage
async def query_database():
async with database_connection("localhost") as conn:
result = await conn.query("SELECT * FROM users")
return result
asyncio.run(main())Expected Output:
Connecting to localhost... Using: Connected to localhost Closing connection to localhost...
Real-World Example: Connection Pool
import asyncio
from contextlib import asynccontextmanager
class AsyncConnectionPool:
def __init__(self, max_connections=10):
self.max_connections = max_connections
self.available = []
self.in_use = set()
self.semaphore = asyncio.Semaphore(max_connections)
self._next_id = 0
async def create_connection(self):
"""Create a new connection"""
await asyncio.sleep(0.1) # Simulate connection time
# Use a counter, not id(object()): the temporary object is freed
# immediately and CPython reuses the address, so every connection
# would end up with the same name and the set below would collapse
self._next_id += 1
return f"Connection-{self._next_id}"
@asynccontextmanager
async def acquire(self):
"""Acquire a connection from the pool"""
async with self.semaphore:
# Get existing or create new
if self.available:
conn = self.available.pop()
else:
conn = await self.create_connection()
self.in_use.add(conn)
try:
yield conn
finally:
# Return to pool
self.in_use.remove(conn)
self.available.append(conn)
# Usage
async def worker(pool, worker_id):
async with pool.acquire() as conn:
print(f"Worker {worker_id} using {conn}")
await asyncio.sleep(1) # Simulate work
print(f"Worker {worker_id} done")
async def main():
pool = AsyncConnectionPool(max_connections=3)
# 10 workers sharing 3 connections
await asyncio.gather(*[
worker(pool, i) for i in range(10)
])
asyncio.run(main())Expected Output:
Worker 0 using Connection-1 Worker 1 using Connection-2 Worker 2 using Connection-3 Worker 0 done Worker 1 done Worker 2 done Worker 3 using Connection-3 Worker 4 using Connection-2 Worker 5 using Connection-1 Worker 3 done Worker 4 done Worker 5 done Worker 6 using Connection-1 Worker 7 using Connection-2 Worker 8 using Connection-3 Worker 6 done Worker 7 done Worker 8 done Worker 9 using Connection-3 Worker 9 done
- Guarantees cleanup even if exceptions occur
- Makes resource management explicit and safe
- Prevents resource leaks in long-running applications
- Works seamlessly with async operations
Async Iterators & Generators
Async iterators allow you to iterate over data that's fetched asynchronously, perfect for streaming large datasets, paginated APIs, or real-time data feeds.
async for: Consuming Async Iterators
import asyncio
# Async generator function
async def fetch_pages(num_pages):
"""Simulate fetching pages one at a time"""
for page in range(1, num_pages + 1):
print(f"Fetching page {page}...")
await asyncio.sleep(1) # Simulate API call
yield {"page": page, "data": f"Content of page {page}"}
# Consuming with async for
async def main():
async for page in fetch_pages(5):
print(f"Processing: {page}")
# Process each page as it arrives
# Memory efficient - one page at a time
asyncio.run(main())Expected Output:
Fetching page 1...
Processing: {'page': 1, 'data': 'Content of page 1'}
Fetching page 2...
Processing: {'page': 2, 'data': 'Content of page 2'}
Fetching page 3...
Processing: {'page': 3, 'data': 'Content of page 3'}
Fetching page 4...
Processing: {'page': 4, 'data': 'Content of page 4'}
Fetching page 5...
Processing: {'page': 5, 'data': 'Content of page 5'}Creating Async Iterators
import asyncio
import aiohttp
# Method 1: Using __aiter__ and __anext__
class AsyncAPIIterator:
def __init__(self, base_url, max_pages):
self.base_url = base_url
self.max_pages = max_pages
self.current_page = 1
def __aiter__(self):
"""Return the iterator object (self)"""
return self
async def __anext__(self):
"""Return the next item or raise StopAsyncIteration"""
if self.current_page > self.max_pages:
raise StopAsyncIteration
# Fetch page asynchronously
url = f"{self.base_url}?_page={self.current_page}&_limit=2"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
self.current_page += 1
return data
# Method 2: Async Generator (simpler)
async def paginated_api(base_url, max_pages):
"""Async generator - cleaner syntax"""
for page in range(1, max_pages + 1):
async with aiohttp.ClientSession() as session:
url = f"{base_url}?_page={page}&_limit=2"
async with session.get(url) as response:
data = await response.json()
yield data
# Usage: both produce the same pages, so print the ids to keep it short
API = "https://jsonplaceholder.typicode.com/posts"
async def main():
print("Class-based iterator:")
async for page_data in AsyncAPIIterator(API, 3):
print(f" page ids: {[row['id'] for row in page_data]}")
print("Async generator:")
async for page_data in paginated_api(API, 3):
print(f" page ids: {[row['id'] for row in page_data]}")
asyncio.run(main())Expected Output:
Class-based iterator: page ids: [1, 2] page ids: [3, 4] page ids: [5, 6] Async generator: page ids: [1, 2] page ids: [3, 4] page ids: [5, 6]
Real-World: Streaming Database Results
This one needs a real PostgreSQL rather than a stand-in, because the whole point is the server-side cursor. A container is the quickest way to get one, and you can delete it afterwards without leaving anything behind:
# A throwaway PostgreSQL. The user, password and database name match the
# DSN in the example below, so it runs without editing anything.
docker run -d --name asyncpg-demo \
-e POSTGRES_USER=user \
-e POSTGRES_PASSWORD=pass \
-e POSTGRES_DB=db \
-p 5432:5432 \
postgres:16-alpine
# The first start takes a few seconds, so wait for it rather than guessing.
# Run pg_isready straight after docker run and it answers "no response".
until docker exec asyncpg-demo pg_isready -U user -d db > /dev/null 2>&1; do
sleep 1
done
docker exec asyncpg-demo pg_isready -U user -d db
pip install asyncpg
# When you are finished, throw it away
docker rm -f asyncpg-demoExpected Output:
/var/run/postgresql:5432 - accepting connections
Then create the table the example reads from:
-- The table this example streams. 25,000 rows is enough to see the
-- batching without waiting around. Pipe this into the container:
-- docker exec -i asyncpg-demo psql -U user -d db < setup.sql
CREATE TABLE large_table (
id serial PRIMARY KEY,
payload text
);
INSERT INTO large_table (payload)
SELECT 'row-' || g FROM generate_series(1, 25000) g;import asyncio
import asyncpg
DSN = 'postgresql://user:pass@localhost/db'
async def stream_large_table(pool, batch_size=1000):
"""Stream millions of rows without loading all into memory"""
async with pool.acquire() as conn:
# A cursor keeps the rows on the server; only batch_size at a
# time crosses into Python. It has to run inside a transaction.
async with conn.transaction():
cursor = await conn.cursor(
"SELECT * FROM large_table ORDER BY id"
)
while True:
# Fetch batch
rows = await cursor.fetch(batch_size)
if not rows:
break
# Yield batch for processing
for row in rows:
yield row
async def process_record(record):
"""Stand-in for whatever real work happens per row"""
return record['id']
# Usage
async def process_records():
pool = await asyncpg.create_pool(DSN)
processed = 0
async for record in stream_large_table(pool):
# Process one record at a time
await process_record(record)
processed += 1
if processed % 10000 == 0:
print(f"Processed {processed} records...")
await pool.close()
print(f"Total processed: {processed}")
asyncio.run(process_records())Expected Output:
Processed 10000 records... Processed 20000 records... Total processed: 25000
Only batch_size rows are in memory at any moment, so the table can be far larger than RAM and the shape of the code does not change. Processing also starts on the first batch rather than after the last row has landed, which matters when the query would take minutes to drain. Note that conn.cursor()has to run inside conn.transaction(): the server-side cursor only exists for the life of that transaction.
Comprehensive Error Handling
Async code needs robust error handling. Unhandled exceptions in tasks can cause silent failures, and errors in concurrent tasks need special attention.
Try/Except in Async Functions
import asyncio
import aiohttp
async def fetch_with_error_handling(url):
"""Proper error handling in async function"""
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
response.raise_for_status() # Raise for 4xx/5xx
return await response.json()
except asyncio.TimeoutError:
print(f"Timeout fetching {url}")
return None
except aiohttp.ClientError as e:
print(f"HTTP error for {url}: {e}")
return None
except Exception as e:
print(f"Unexpected error for {url}: {e}")
return None
finally:
# Cleanup code (always runs)
print(f"Finished attempt for {url}")
# Usage: this id does not exist, so the API answers 404 and
# raise_for_status() turns that into a ClientError
async def main():
result = await fetch_with_error_handling(
"https://jsonplaceholder.typicode.com/posts/99999"
)
if result:
print(f"Success: {result}")
else:
print("Failed to fetch data")
asyncio.run(main())Expected Output:
HTTP error for https://jsonplaceholder.typicode.com/posts/99999: 404, message='Not Found', url='https://jsonplaceholder.typicode.com/posts/99999' Finished attempt for https://jsonplaceholder.typicode.com/posts/99999 Failed to fetch data
Handling Errors in Concurrent Tasks
import asyncio
async def risky_task(task_id):
"""Task that might fail"""
await asyncio.sleep(1)
if task_id == 2:
raise ValueError(f"Task {task_id} failed!")
return f"Task {task_id} succeeded"
# Method 1: gather() with return_exceptions
async def main_gather():
results = await asyncio.gather(
risky_task(1),
risky_task(2), # This will fail
risky_task(3),
return_exceptions=True # Don't crash, return exceptions
)
for i, result in enumerate(results, 1):
if isinstance(result, Exception):
print(f"Task {i} failed: {result}")
else:
print(f"Task {i}: {result}")
# Method 2: Wrapping tasks individually
async def safe_task(task_id):
"""Wrapper with error handling"""
try:
return await risky_task(task_id)
except Exception as e:
print(f"Caught error in task {task_id}: {e}")
return None # Return default value
async def main_wrapped():
results = await asyncio.gather(
safe_task(1),
safe_task(2), # Error caught internally
safe_task(3)
)
print(f"All results: {results}")
# Method 3: Using create_task with error handling
async def main_tasks():
tasks = [
asyncio.create_task(safe_task(i))
for i in range(1, 4)
]
results = await asyncio.gather(*tasks)
print(f"Results: {results}")
asyncio.run(main_gather())Expected Output:
Task 1: Task 1 succeeded Task 2 failed: Task 2 failed! Task 3: Task 3 succeeded
Retry Pattern with Exponential Backoff
import asyncio
import random
async def retry_with_backoff(
coro_func,
max_retries=3,
base_delay=1,
max_delay=60,
exponential_base=2
):
"""Retry with exponential backoff"""
for attempt in range(max_retries):
try:
return await coro_func()
except Exception as e:
if attempt == max_retries - 1:
# Last attempt, re-raise
raise
# Calculate delay with exponential backoff
delay = min(
base_delay * (exponential_base ** attempt),
max_delay
)
# Add jitter to prevent thundering herd
jitter = random.uniform(0, delay * 0.1)
total_delay = delay + jitter
print(f"Attempt {attempt + 1} failed: {e}")
print(f"Retrying in {total_delay:.2f}s...")
await asyncio.sleep(total_delay)
# Usage
# Seeded so this example prints the same thing every run. Drop the seed
# and you get a different number of retries each time, which is exactly
# what the backoff logic is there to handle.
random.seed(10)
async def unreliable_api_call():
"""Simulates unreliable API"""
if random.random() < 0.7:
raise Exception("API temporarily unavailable")
return {"data": "success"}
async def main():
try:
result = await retry_with_backoff(
unreliable_api_call,
max_retries=5,
base_delay=1
)
print(f"Success: {result}")
except Exception as e:
print(f"Failed after all retries: {e}")
asyncio.run(main())Expected Output:
Attempt 1 failed: API temporarily unavailable
Retrying in 1.04s...
Attempt 2 failed: API temporarily unavailable
Retrying in 2.04s...
Success: {'data': 'success'}Production-Ready Patterns
Producer-consumer decoupled by an asyncio.Queue
Figure 1: The queue is a buffer between producers and consumers - producers never wait on consumers. queue.join() unblocks once every put() has a matching task_done().
Timeout Context Manager
Prevent operations from hanging indefinitely.
import asyncio
async def slow_operation():
"""Stand-in for something that takes longer than we want to wait"""
await asyncio.sleep(30)
return "finished"
async def operation_with_timeout():
try:
# Timeout after 5 seconds
async with asyncio.timeout(5):
result = await slow_operation()
return result
except asyncio.TimeoutError:
print("Operation timed out!")
return None
# Alternative: wait_for
async def alternative_timeout():
try:
result = await asyncio.wait_for(
slow_operation(),
timeout=5.0
)
return result
except asyncio.TimeoutError:
print("Operation timed out!")
return None
# Nested timeouts
async def nested_timeouts():
try:
# Outer timeout: 10 seconds
async with asyncio.timeout(10):
# Inner timeout: 5 seconds
async with asyncio.timeout(5):
result = await slow_operation()
# This has 5 seconds remaining
await another_operation()
except asyncio.TimeoutError:
print("Operation exceeded timeout")
asyncio.run(operation_with_timeout())Expected Output:
Operation timed out!
Testing Async Code
Testing async code requires special considerations and tools. Install pytest and pytest-asyncio, then tell pytest to treat async tests and fixtures as coroutines:
# pytest.ini - asyncio_mode=auto lets async fixtures work without # decorating each one with @pytest_asyncio.fixture [pytest] asyncio_mode = auto
import asyncio
import pytest
# Using pytest-asyncio. Keep the plain async test off the network: the
# mocked test below is how you cover the code that does make requests.
@pytest.mark.asyncio
async def test_async_function():
"""Test async function with pytest"""
result = await task(3)
assert result == 30
# Testing with mocks
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_with_mock():
"""Test with mocked async calls"""
mock_response = AsyncMock()
mock_response.json.return_value = {"data": "test"}
# session.get(url) is used as an async context manager, so the code
# under test receives __aenter__'s return value, not get()'s. Patching
# get(return_value=mock_response) leaves .json() on the wrong object
# and the assertion then compares against a MagicMock.
with patch('aiohttp.ClientSession.get') as mock_get:
mock_get.return_value.__aenter__.return_value = mock_response
result = await fetch_data("https://api.example.com")
assert result["data"] == "test"
# Testing timeouts
@pytest.mark.asyncio
async def test_timeout():
"""Test that operation times out"""
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(slow_operation(), timeout=1.0)
# Testing concurrent operations
@pytest.mark.asyncio
async def test_concurrent_execution():
"""Test multiple concurrent operations"""
start = asyncio.get_running_loop().time()
results = await asyncio.gather(
task(1), task(2), task(3)
)
elapsed = asyncio.get_running_loop().time() - start
assert len(results) == 3
assert elapsed < 2.0 # Should run concurrently
# Fixture for async setup/teardown
@pytest.fixture
async def db_connection():
"""Async fixture"""
conn = await create_connection()
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_with_fixture(db_connection):
"""Use async fixture"""
result = await db_connection.query("SELECT 1")
assert result is not NoneExpected Output:
$ pytest -v ============================= test session starts ============================== platform linux -- Python 3.12.13, pytest-9.1.1, pluggy-1.6.0 configfile: pytest.ini plugins: asyncio-1.4.0 asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collecting ... collected 5 items test_async.py::test_async_function PASSED [ 20%] test_async.py::test_with_mock PASSED [ 40%] test_async.py::test_timeout PASSED [ 60%] test_async.py::test_concurrent_execution PASSED [ 80%] test_async.py::test_with_fixture PASSED [100%] ============================== 5 passed in 1.72s ===============================
Async Best Practices
- Use async context managers for resources
- Always handle exceptions in tasks
- Use
return_exceptions=Trueingather() - Implement timeouts for all I/O operations
- Use async iterators for streaming data
- Implement retry logic with backoff
- Use circuit breakers for external services
- Limit concurrency with semaphores
- Log errors with context
- Test async code thoroughly
- Letting exceptions crash tasks silently
- No timeouts on I/O operations
- Unbounded concurrency
- Mixing blocking and async code
- Not using async context managers
- Ignoring backpressure
- No retry logic for transient failures
- Creating too many tasks at once
- Not testing edge cases
- Swallowing errors without logging
Key Takeaways
- Async context managers ensure cleanup - use async with for resources
- Async iterators enable streaming - process data as it arrives
- Error handling is critical - use return_exceptions and try/except
- Implement retry logic - exponential backoff for resilience
- Use circuit breakers - prevent cascading failures
- Timeouts prevent hangs - always set reasonable timeouts
- Worker pools limit concurrency - prevent resource exhaustion
- Test async code properly - use pytest-asyncio and mocks
Practice Exercises
Exercise 1: Async Database Pool
Build an async connection pool for database connections with proper context manager support. Implement connection timeout, max pool size, and connection recycling. Test with concurrent queries.
Exercise 2: Resilient API Client
Create an async API client with circuit breaker, retry logic with exponential backoff, rate limiting using semaphore, and comprehensive error handling. Should gracefully handle timeouts, network errors, and API rate limits.
Exercise 3: Streaming Data Processor
Build an async data processor that streams records from a paginated API using async iterators, processes them in batches using a worker pool, and handles errors without stopping the entire pipeline. Include metrics (records processed, errors encountered).
Additional Resources
- asyncio docs: docs.python.org/3/library/asyncio.html
- aiohttp: docs.aiohttp.org - async HTTP client/server
- pytest-asyncio: Testing async code
- tenacity: Retry library for Python
- Design Patterns: "Designing Data-Intensive Applications" by Martin Kleppmann
- Circuit Breakers: martinfowler.com/bliki/CircuitBreaker.html
What's Next?
You've mastered async patterns! Now let's explore threading and concurrency to handle parallel execution with threads.
- Threading Basics - Create and manage threads for concurrent execution
- Thread Synchronization - Locks, semaphores, and thread-safe code
- GIL Understanding - Learn about Python's Global Interpreter Lock