The Promise
Async Python sounds straightforward: replace def with async def, use await on I/O calls, and your API handles 10x more concurrent requests. The event loop multiplexes work while waiting for databases, HTTP calls, and file I/O.
In benchmarks, this is true. In production, it's more nuanced.
What Actually Changes
1. Error Handling Gets Weird
Synchronous Python errors are predictable — they propagate up the call stack and your exception handler catches them. Async introduces new failure modes:
# This silently swallows the error
async def process_batch(items):
tasks = [process_item(item) for item in items]
results = await asyncio.gather(*tasks, return_exceptions=True)
# If you don't check results, exceptions are silently lost
for result in results:
if isinstance(result, Exception):
logger.error(f"Task failed: {result}")asyncio.gather with return_exceptions=True returns exceptions as values, not raises them. If you don't explicitly check, errors vanish. We had a bug where 5% of loan applications silently failed for two weeks because of this pattern.
2. Connection Pools Are Critical
Every async database driver needs explicit pool configuration:
# Without pool limits, you WILL exhaust database connections
engine = create_async_engine(
DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_timeout=30,
pool_recycle=1800,
)In sync code, each request holds one connection for its duration. In async code, a single worker can have 100+ concurrent requests, each needing a connection. Without pool limits, your database dies under load.
Sizing heuristic: pool_size = 2 * CPU_cores as a starting point, then tune with load testing.
3. Not Everything Should Be Async
CPU-bound operations in async code block the event loop:
# BAD: this blocks the event loop
async def compute_risk_score(data):
# Heavy computation — no I/O waits
score = expensive_calculation(data) # blocks!
return score
# GOOD: offload to thread pool
async def compute_risk_score(data):
loop = asyncio.get_event_loop()
score = await loop.run_in_executor(
None, expensive_calculation, data
)
return scoreIf a function doesn't await anything, making it async gains nothing and can make things worse.
4. Stack Traces Are Hard to Read
Async stack traces include event loop internals that obscure the actual error:
Traceback (most recent call last):
File "uvicorn/protocols/http/h11_impl.py", line 404, in run_asgi
File "uvicorn/middleware/proxy_headers.py", line 78, in __call__
File "fastapi/applications.py", line 1054, in __call__
File "starlette/middleware/errors.py", line 163, in __call__
... 15 more framework lines ...
File "app/services/loan.py", line 42, in get_loan
ValueError: Invalid loan ID format
The actual error is at the bottom, buried under framework noise. We added structured logging that captures the relevant context alongside the error, not just the stack trace.
5. Testing Async Code Requires Different Patterns
# pytest-asyncio for async test functions
import pytest
@pytest.mark.asyncio
async def test_loan_retrieval():
async with AsyncClient(app=app) as client:
response = await client.get("/loans/123")
assert response.status_code == 200
# Mock async functions differently
from unittest.mock import AsyncMock
mock_db = AsyncMock(return_value=loan_data)Every mock needs to be AsyncMock, not Mock. Every test function needs @pytest.mark.asyncio. Every HTTP client needs to be async. The testing overhead is real.
When to Use Async
| Use Case | Async Worthwhile? |
|---|---|
| API with many upstream HTTP calls | Yes — concurrent I/O waits |
| API with database queries | Usually yes — connection pooling + concurrency |
| CPU-heavy computation | No — use multiprocessing |
| Low-traffic internal tool | No — sync is simpler |
| WebSocket / streaming | Yes — designed for long-lived connections |
The Honest Assessment
Async Python delivers real throughput gains for I/O-bound APIs. Our loan retrieval service handles 3x more concurrent requests with the same hardware. But the engineering cost is real:
- Debugging is harder
- Connection pools need careful sizing
- Error handling has new failure modes
- Testing requires async-specific tooling
- Team onboarding takes longer
The tradeoff is worth it when your service is I/O-bound and handles significant concurrent load. For everything else, sync Python is simpler, more debuggable, and perfectly adequate.