asyncio gets reached for a lot in Python without a clear sense of when it actually helps — and it's easy to write code that looks async but runs synchronously anyway.
What async actually buys you
Async Python doesn't make individual operations faster, and it doesn't give you true parallelism (that's what multiprocessing is for). What it buys you is concurrency during I/O waits — while one task is waiting on a network response, disk read, or database query, the event loop can run other tasks instead of sitting idle.
That means async is a genuine win for I/O-bound work (API calls, database queries, file operations) and does nothing for CPU-bound work (heavy computation) — an async function doing math in a tight loop blocks the event loop exactly like a synchronous one would.
The basic shape
import asyncio
import httpx
async def fetch_user(client, user_id):
response = await client.get(f"/api/users/{user_id}")
return response.json()
async def main():
async with httpx.AsyncClient() as client:
user = await fetch_user(client, 42)
print(user)
asyncio.run(main())async def marks a function as a coroutine — calling it doesn't run it, it creates a coroutine object that needs to be awaited or scheduled. await yields control back to the event loop while waiting on the awaited operation, letting other coroutines run in the meantime.
The actual payoff: concurrent requests
async def fetch_all_users(client, user_ids):
tasks = [fetch_user(client, uid) for uid in user_ids]
return await asyncio.gather(*tasks)This is where async pays off. asyncio.gather runs all the requests concurrently — instead of waiting for each API call to finish before starting the next (as a synchronous loop would), all the requests are in flight simultaneously, and the total time is roughly the duration of the slowest single request, not the sum of all of them.
The mistake that quietly kills concurrency
# Looks async, runs sequentially — the bug
async def fetch_all_users_broken(client, user_ids):
results = []
for uid in user_ids:
result = await fetch_user(client, uid) # blocks here every time
results.append(result)
return resultsAwaiting inside a loop, one call at a time, gets you back to sequential execution — each await fully completes before the loop moves to the next iteration. This is a very common way async code ends up with none of async's actual benefit. If you want concurrency, gather the coroutines first, then await them together.
A second common mistake: blocking calls inside async functions
async def bad_fetch():
import requests
return requests.get("https://api.example.com") # synchronous, blocks the whole event loopUsing a synchronous library (like requests, or plain file I/O, or time.sleep) inside an async def function blocks the entire event loop — not just that one coroutine, but every other coroutine waiting to run. Every library in the call chain of an async function needs to be async-native (httpx instead of requests, aiofiles instead of built-in file I/O, asyncio.sleep instead of time.sleep) or the concurrency benefit disappears silently, with no error to warn you.
When to reach for it
Async Python earns its complexity when you're making many independent I/O calls that could run concurrently — a web scraper hitting dozens of URLs, a server handling many simultaneous requests, a script fetching from multiple APIs. For a script that makes one API call and does some math with the result, async adds ceremony without adding speed.