Your Python service handles 1,000 requests per second. Traffic spikes to 10,000. Threads exhaust memory. The event loop stalls. Users see timeouts. This isn't a hypothetical — it's the moment every Python team faces when synchronous code meets real scale.
Why Async Changed Everything
Python's asyncio isn't new. But in 2026, it's the default choice for I/O-bound services. The reason: modern libraries eliminated the friction. uvloop drops in as a faster event loop. httpx and aiohttp handle millions of concurrent connections. asyncpg and Motor saturate database bandwidth. pytest-asyncio makes testing feel synchronous.
"
Async isn't about speed. It's about capacity. One thread handles 10,000 idle connections while waiting for I/O.
— Guido van Rossum
The Minimal Viable Async Service
python
import asyncio
import uvloop
from httpx import AsyncClient
async def fetch_all(urls: list[str]) -> list[dict]:
async with AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [r.json() for r in responses]
if __name__ == "__main__":
uvloop.install()
urls = [f"https://api.example.com/item/{i}" for i in range(100)]
data = asyncio.run(fetch_all(urls))
print(f"Fetched {len(data)} items")
💡
TipAlways call uvloop.install() before asyncio.run(). It replaces the default loop with a libuv-backed implementation — 2-4x faster for network I/O.
Database Access Without Blocking
Synchronous drivers kill async throughput. One slow query blocks the entire loop. asyncpg for PostgreSQL and Motor for MongoDB use native async protocols. They pipeline commands, reuse connections, and never block the event loop.
python
import asyncpg
async def get_users(pool: asyncpg.Pool, ids: list[int]) -> list[dict]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM users WHERE id = ANY($1)",
ids
)
return [dict(row) for row in rows]
# Startup: create pool once
pool = await asyncpg.create_pool(
"postgresql://user:pass@localhost/db",
min_size=10,
max_size=50
)
⚠️
WarningNever create a connection pool per request. Initialize once at startup. Size it to your CPU cores × 4 for optimal throughput.
Patterns That Prevent Production Failures
Anti-pattern
Fix
Impact
await in loop
asyncio.gather()
Serial → parallel I/O
No timeout
asyncio.wait_for()
Hanging requests killed
Unbounded tasks
asyncio.Semaphore(100)
Memory exhaustion prevented
Fire-and-forget
TaskGroup (3.11+)
Exceptions observed, cleanup guaranteed
python
async def bounded_fetch(urls: list[str], limit: int = 100) -> list[dict]:
sem = asyncio.Semaphore(limit)
async with AsyncClient(timeout=10.0) as client:
async def fetch_one(url: str):
async with sem:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
return await asyncio.gather(*[fetch_one(u) for u in urls])
Testing Async Code Like It's Synchronous
pytest-asyncio removes the boilerplate. Mark tests async. Use fixtures for pools and clients. Mock external services with respx or aioresponses. Run tests in parallel with pytest-xdist — each worker gets its own event loop.
Pick one synchronous bottleneck in your service — an API call, a database query, a file operation. Rewrite it async this week. Add uvloop. Set timeouts. Bound concurrency. Measure latency under load. The tooling is stable. The patterns are proven. The only variable is when you start.