When your API needs to juggle multiple downstream calls—database queries, external service requests, cache fills—synchronous code grinds to a halt. Async/await gives you the tools, but without discipline, you'll starve threads, blow past rate limits, or hang forever. Python async patterns for APIs: best practices explained are what separate resilient systems from fragile ones that crumble under load.
Why async matters for APIs
A typical API request handler might need to fetch user data, check inventory, call a payment processor, and log an event. In a synchronous world, each call blocks the thread. In an async world, you can overlap them. But async is not magic—it requires intentional patterns to keep your API fast, safe, and predictable.
Most teams learn this the hard way: they switch to async, forget to implement backpressure, and watch their application merrily hammer a third-party API into rate-limit errors. Or they add timeouts everywhere except the places that matter most. The patterns we'll cover today prevent those disasters.
Fan out with gather — Call several services at once
Fan-out is the simplest async win: instead of calling Service A, waiting for it, then calling Service B, call both at once and collect both results.
Real-world scenario: Your e-commerce API needs to fetch product details, user order history, and current pricing. Each call takes 200ms. Sequentially, that's 600ms. With asyncio.gather(), it's 200ms.
import asyncio
import httpx
async def get_product_details(product_id: int):
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://api.internal/products/{product_id}")
return resp.json()
async def get_user_history(user_id: int):
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://api.internal/users/{user_id}/orders")
return resp.json()
async def get_current_price(product_id: int):
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://pricing.internal/prices/{product_id}")
return resp.json()
async def fetch_order_page(product_id: int, user_id: int):
# Call all three services concurrently
product, history, price = await asyncio.gather(
get_product_details(product_id),
get_user_history(user_id),
get_current_price(product_id)
)
return {
"product": product,
"user_history": history,
"price": price
}
The key insight: asyncio.gather() starts all coroutines before awaiting any of them. If one fails and you don't set return_exceptions=True, the whole gather raises immediately. Use return_exceptions=True if you want partial results:
results = await asyncio.gather(
get_product_details(product_id),
get_user_history(user_id),
get_current_price(product_id),
return_exceptions=True
)
# results is a list; check isinstance(r, Exception) for failures
Limit concurrency — A semaphore stops you from flooding a downstream API
Fan-out is powerful, but fan-out without bounds is dangerous. Imagine you need to fetch 1,000 user profiles. Calling gather() on 1,000 coroutines opens 1,000 concurrent connections. Most APIs will rate-limit or drop you. Your own connection pool will exhaust.
Real-world scenario: A SaaS platform reconciles data with a third-party API that allows 10 concurrent connections. You have 500 items to sync. Without a semaphore, you'd try to open 500 connections at once and get blacklisted.
asyncio.Semaphore is your guard. It limits how many coroutines can enter a critical section simultaneously.
import asyncio
import httpx
sem = asyncio.Semaphore(10) # Allow 10 concurrent calls
async def fetch_with_limit(client: httpx.AsyncClient, url: str):
async with sem: # Acquire semaphore slot
resp = await client.get(url)
return resp.json()
async def sync_all_users(user_ids: list[int]):
async with httpx.AsyncClient() as client:
tasks = [
fetch_with_limit(client, f"https://api.external/users/{uid}")
for uid in user_ids
]
results = await asyncio.gather(*tasks)
return results
The semaphore ensures that even though 500 tasks are created, only 10 are awaiting the HTTP call at any moment. The rest wait their turn. This pattern keeps your resource consumption predictable and respects downstream rate limits.
Pro tip: Make semaphore limits configurable. A setting of 10 might be right for one API and wrong for another. Some teams expose this as an environment variable or even a per-endpoint config in their service.
Timeouts everywhere — asyncio.timeout wraps any await
Async code without timeouts is a ticking bomb. If a downstream service hangs, your coroutine hangs. If you have no max wait time, you hang forever. In production, "forever" means your API stops responding, traffic queues up, and you get paged at 3 AM.
Real-world scenario: A third-party payment processor has a network blip and stops responding. Your checkout endpoint awaits the payment call with no timeout. Requests pile up. Within seconds, all your API workers are blocked on that one service.
Python 3.11+ offers asyncio.timeout(), which cleanly cancels any await inside its context:
import asyncio
import httpx
async def charge_card(card_token: str, amount: float):
try:
async with asyncio.timeout(5): # 5 second timeout
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://payment.processor/charge",
json={"token": card_token, "amount": amount}
)
return resp.json()
except asyncio.TimeoutError:
return {"error": "Payment processor timed out", "status": "retry"}
async def checkout(user_id: int, cart_id: int):
card = await get_user_card(user_id)
try:
payment = await charge_card(card["token"], 99.99)
if "error" in payment:
return {"status": "timeout", "message": payment["error"]}
order = await create_order(user_id, cart_id, payment["transaction_id"])
return {"status": "success", "order_id": order["id"]}
except Exception as e:
return {"status": "error", "message": str(e)}
Before Python 3.11, use asyncio.wait_for():
try:
result = await asyncio.wait_for(charge_card(card_token, amount), timeout=5)
except asyncio.TimeoutError:
# Handle timeout
pass
Apply timeouts at multiple levels: per-request (5s), per-database-query (2s), per-third-party-call (10s). Layers of timeouts catch different failure modes. A query that individually times out at 2s but is part of a 100-query fan-out should still respect a 10s overall timeout for the whole operation.
TaskGroup — Python 3.11 TaskGroup cancels siblings when one fails
In older async code, asyncio.gather() with return_exceptions=True forces you to inspect results manually. With asyncio.TaskGroup (Python 3.11+), exceptions propagate naturally, and all sibling tasks are cancelled if one raises.
Real-world scenario: You're processing an order that requires inventory check, tax calculation, and fraud detection. If fraud detection fails, you want to stop immediately—don't charge, don't reserve inventory, don't calculate tax. TaskGroup ensures all three tasks are cleaned up.
import asyncio
async def check_inventory(product_id: int, quantity: int):
# Simulated check
await asyncio.sleep(0.5)
return {"available": quantity <= 100}
async def calculate_tax(amount: float, region: str):
# Simulated tax calculation
await asyncio.sleep(0.3)
return {"tax": amount * 0.08}
async def check_fraud(user_id: int, amount: float):
# Simulated fraud check
await asyncio.sleep(0.2)
if amount > 10000:
raise ValueError("Suspicious transaction amount")
return {"risk_score": 0.1}
async def process_order(user_id: int, product_id: int, quantity: int, amount: float):
try:
async with asyncio.TaskGroup() as tg:
inv_task = tg.create_task(check_inventory(product_id, quantity))
tax_task = tg.create_task(calculate_tax(amount, "US"))
fraud_task = tg.create_task(check_fraud(user_id, amount))
# If we reach here, all three completed successfully
inventory = inv_task.result()
tax = tax_task.result()
fraud = fraud_task.result()
return {
"status": "approved",
"inventory": inventory,
"tax": tax,
"fraud_check": fraud
}
except ExceptionGroup as eg:
# Multiple tasks may have raised; eg.exceptions is a tuple
return {"status": "failed", "errors": [str(e) for e in eg.exceptions]}
except Exception as e:
return {"status": "failed", "error": str(e)}
If check_fraud raises for a $15,000 order, the TaskGroup immediately cancels check_inventory and calculate_tax even if they're still running. This prevents wasted work and keeps your order pipeline clean.
TaskGroup also handles ExceptionGroup, which wraps multiple exceptions if several tasks fail at once. This is crucial for understanding what went wrong.
Common mistakes
- Creating the semaphore inside the async function: This creates a new semaphore for every call. The semaphore must be created once at module load and reused. Move it outside the function or into a class.
- Forgetting to await in gather:
await asyncio.gather(task1, task2)works;asyncio.gather(task1, task2)(without await) just creates a coroutine that never runs. The tasks never execute. - Setting timeouts too aggressively: A 1-second timeout for a database query that occasionally takes 2-3 seconds under load is worse than no timeout. Measure production latencies first, then set timeouts 2–3x higher.
- Not handling TimeoutError separately: Treat timeouts as a distinct failure mode from other exceptions. Retry logic, logging, and user messaging differ for timeouts vs. application errors.
- Mixing sync and async without care: Calling a sync function from async code blocks the event loop. If you must call sync code, use
loop.run_in_executor()to run it in a thread pool.
Key takeaways
- Use
asyncio.gather()to fan out multiple concurrent calls and reduce latency. - Protect downstream services with
asyncio.Semaphoreto enforce concurrency limits and respect rate limits. - Apply
asyncio.timeout()at every layer—per-request, per-query, per-operation—to prevent hangs. - Upgrade to Python 3.11+ and use
TaskGroupfor cleaner error handling and automatic cancellation of sibling tasks. - Measure production latencies before setting timeouts and configure semaphore limits per environment or per endpoint.
- Async patterns shine when you orchestrate multiple I/O operations, but they require discipline to avoid rate-limit errors, resource exhaustion, and indefinite hangs.






Comments
Loading comments…