The Story That Explains Async / Await
Now imagine a smarter barista. Order taken → espresso started → while it runs, the barista takes the next order, starts another shot, steams milk for order #1, checks on order #2's shot. The barista never idles. Same one person. Same two hands. But throughput triples because they never wait on the machine.
That second barista is async / await. Python doesn't get more threads or more cores — it just stops standing around while waiting for slow things (network, disk, timers).
async / await is Python's way of writing code that pauses cleanly at slow I/O points and lets other work run during the wait. It is single-threaded, cooperative, and dramatically faster for anything that spends most of its time waiting on the network, a database, files, or timers.
async / await does not make CPU work faster. It makes waiting faster — by removing the wait entirely and doing something else in that gap. Use it when your program spends time waiting on external things, not when it spends time computing.
The Problem — Why Sync Code Is Slow at I/O
Consider fetching three URLs. Each one takes ~1 second because the network is slow. Synchronous code stops the whole program during every wait.
| Step | Time |
|---|---|
| Fetch URL 1 | 0.0s → 1.0s |
| Fetch URL 2 | 1.0s → 2.0s |
| Fetch URL 3 | 2.0s → 3.0s |
| Total | 3.0 seconds |
| Step | Time |
|---|---|
| Start URL 1 | 0.0s (returns instantly) |
| Start URL 2 | 0.0s (returns instantly) |
| Start URL 3 | 0.0s (returns instantly) |
| All finish | ~1.0 second |
The three async tasks all hit their await point almost immediately — the event loop parks them together, and all three timers expire at the same moment.
In the synchronous version, your CPU sits at ~0% usage for 3 full seconds while it waits for the network. That's wasted machine time. Async reclaims it — and for I/O-heavy programs (web scrapers, APIs, chat bots, database workers), the speedup is often 10× to 100×.
The Three Building Blocks
x finishes."
Only usable inside async def functions.
asyncio.run().
Your First Async Function
import asyncio
async def greet(name):
print(f"Hello {name}, starting...")
await asyncio.sleep(1) # pause 1s WITHOUT blocking
print(f"Goodbye {name}")
# Calling greet() only creates a coroutine — nothing runs yet
# asyncio.run() starts the event loop and executes it
asyncio.run(greet("Mohit"))
time.sleep(1) blocks the entire thread — even in async code
nothing else can run. await asyncio.sleep(1) yields for 1 second,
allowing other coroutines to run during the pause. Always use the async version
inside async code.
Visual Diagram — Inside the Event Loop
Only ONE coroutine runs on the CPU at any instant (solid arrow into loop). The others sit in "suspended" (dashed arrows). When their I/O finishes, the OS notifies the loop and they become "ready" — and take their turn on the CPU.
Step-by-Step: What Happens on Every Tick
main() coroutine, and starts spinning the loop. This is the ONLY entry point you should use in a script.await. That's the cooperative handover point — nothing preempts a coroutine mid-CPU-work.asyncio.run() tears down the loop and returns the result of your main coroutine.All of this happens on one OS thread. There are no locks, no race conditions between coroutines at the language level, no GIL contention. That simplicity is why async has taken over networked Python — from FastAPI to Discord bots to database drivers.
Running Things Concurrently — asyncio.gather
A single await on its own doesn't buy you anything — it just waits.
The speedup comes from running multiple coroutines at once with
asyncio.gather().
Sequential vs Concurrent — the Same Task, Two Ways
import asyncio, time
async def fetch(name, delay):
print(f"→ start {name}")
await asyncio.sleep(delay) # simulate network
print(f"✓ done {name}")
return f"{name}-result"
async def sequential():
t0 = time.perf_counter()
a = await fetch("A", 1) # wait for A
b = await fetch("B", 1) # then wait for B
c = await fetch("C", 1) # then wait for C
print(f"Sequential: {time.perf_counter()-t0:.2f}s")
async def concurrent():
t0 = time.perf_counter()
a, b, c = await asyncio.gather(
fetch("A", 1),
fetch("B", 1),
fetch("C", 1),
)
print(f"Concurrent: {time.perf_counter()-t0:.2f}s")
asyncio.run(sequential())
asyncio.run(concurrent())
Reading left-to-right: each task briefly runs (orange), immediately hits await, and enters SUSPENDED (faint bar). The event loop juggles all three during the 1-second window. When the timers fire, each task briefly runs again to finish.
All three fetch() calls hit their await asyncio.sleep(1)
almost simultaneously. The event loop then had three coroutines all waiting
for the same 1-second timer. When the timer fired, all three resumed
one after another. Total wall time = the longest single wait, not the sum.
Tasks — Fire-and-Continue Concurrency
asyncio.gather() waits for a fixed set of coroutines to all finish.
Sometimes you want to start a coroutine, continue your own work,
and collect the result later. That's what a Task is —
a coroutine wrapped in a scheduled unit of work.
task = asyncio.create_task(coro()) — schedules the coroutine on the loop immediately.
await point.
result = await task — pause until the task finishes and get its return value.
task.cancel() — request cancellation. A CancelledError is raised inside the task at its next await.
import asyncio
async def slow_price_lookup(symbol):
await asyncio.sleep(2) # pretend this is an API call
return {"symbol": symbol, "price": 142.35}
async def main():
# Fire the task — it starts running NOW, in the background
task = asyncio.create_task(slow_price_lookup("AAPL"))
# Meanwhile, do other work — this takes ~1 second
print("doing local calculations...")
await asyncio.sleep(1)
print("calculations done, now waiting on API...")
# Now collect the result. Only ~1 more second of waiting (not 2!)
result = await task
print(f"Got: {result}")
asyncio.run(main())
Sequential await forces you to wait for the API before starting local work. create_task starts the API immediately, and your local work overlaps with the network wait.
Practical Example — Fetching Many URLs with aiohttp
This is where async really shines. aiohttp is the async equivalent of
requests. Ten URLs that would take 10 seconds sequentially can be fetched
in ~1 second concurrently.
# pip install aiohttp
import asyncio, aiohttp, time
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
async def fetch_url(session, url):
async with session.get(url) as resp:
return resp.status, len(await resp.text())
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, u) for u in urls]
return await asyncio.gather(*tasks)
t0 = time.perf_counter()
results = asyncio.run(fetch_all(URLS))
print(f"Fetched {len(results)} URLs in {time.perf_counter()-t0:.2f}s")
for status, size in results:
print(f" status={status} bytes={size}")
Create one aiohttp.ClientSession and pass it to every request.
Creating a session per request destroys the entire benefit — you throw away connection
pooling, DNS caching, and TCP keep-alive. Same rule applies to httpx.AsyncClient.
Timeouts & Cancellation
Network calls hang. Databases stall. You need a way to say "give up if this takes too long."
asyncio.wait_for() and asyncio.timeout() handle this cleanly.
import asyncio
async def slow_task():
await asyncio.sleep(10)
return "done"
async def main():
# Modern style — Python 3.11+
try:
async with asyncio.timeout(2):
result = await slow_task()
except TimeoutError:
print("gave up after 2 seconds")
# Classic style — works on all versions
try:
result = await asyncio.wait_for(slow_task(), timeout=2)
except asyncio.TimeoutError:
print("also gave up after 2 seconds")
asyncio.run(main())
Manually Cancelling a Task
import asyncio
async def worker(n):
try:
while True:
print(f"worker {n} tick")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
print(f"worker {n} shutting down cleanly")
raise # re-raise so cancellation propagates
async def main():
t = asyncio.create_task(worker(1))
await asyncio.sleep(2)
t.cancel() # request cancellation
try:
await t
except asyncio.CancelledError:
pass
asyncio.run(main())
Common Pitfalls (and How to Avoid Them)
| Mistake | What Happens | Fix |
|---|---|---|
time.sleep(2) inside async |
Freezes the entire event loop for 2s | Use await asyncio.sleep(2) |
Calling requests.get() in async |
Blocks the loop — kills concurrency | Use aiohttp or httpx.AsyncClient |
Forgetting await |
Coroutine never runs, silent bug | Any async def call needs an await |
| CPU-heavy math inside async | Starves other coroutines | Offload with asyncio.to_thread(fn) or ProcessPoolExecutor |
Multiple asyncio.run() calls |
Creates a new loop each time — state lost | Call asyncio.run() ONCE at the top level |
| Not using a session for HTTP | No pooling, 10× slower than necessary | One ClientSession, reuse for every request |
| Unawaited task discarded | Task garbage-collected mid-run | Store references: tasks.append(create_task(...)) |
A single time.sleep(), requests.get(), or long-running
pandas operation inside an async def will freeze every other coroutine
until it finishes. Async is cooperative — everyone must yield. When you MUST call
a synchronous library, wrap it: await asyncio.to_thread(blocking_fn, args).
async / await vs Threads vs Processes
| Approach | Best For | Concurrency Model | Overhead per Task |
|---|---|---|---|
| async / await | I/O-bound (network, DB, files) | Cooperative, 1 thread | ~KB — millions possible |
| Threads | I/O-bound legacy code | Preemptive, OS threads (GIL) | ~MB — thousands limit |
| Multiprocessing | CPU-bound (math, ML, image) | Parallel, separate processes | Heavy — one per CPU core |
Is your program waiting on external things? → async / await.
Is it crunching numbers or images? → multiprocessing.
Are you calling a mix of blocking libraries you can't replace? →
threads or asyncio.to_thread().
Real-World Example — Weather Dashboard Aggregator
Let's build something concrete: fetch current weather for 5 cities from a public API in parallel, handle failures gracefully, apply a timeout, and print a formatted summary.
import asyncio, aiohttp, time
CITIES = [
("Mumbai", 19.0760, 72.8777),
("New York", 40.7128, -74.0060),
("London", 51.5074, -0.1278),
("Tokyo", 35.6762, 139.6503),
("Sydney", -33.8688, 151.2093),
]
API = "https://api.open-meteo.com/v1/forecast"
async def fetch_weather(session, city, lat, lon):
params = {"latitude": lat, "longitude": lon, "current_weather": True}
try:
async with asyncio.timeout(5):
async with session.get(API, params=params) as r:
data = await r.json()
cw = data["current_weather"]
return city, cw["temperature"], cw["windspeed"], None
except Exception as e:
return city, None, None, str(e)
async def main():
t0 = time.perf_counter()
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(
*[fetch_weather(session, c, lat, lon) for c, lat, lon in CITIES]
)
print(f"\nWeather report — fetched in {time.perf_counter()-t0:.2f}s")
print("-" * 50)
for city, temp, wind, err in results:
if err:
print(f"{city:12s} ERROR: {err}")
else:
print(f"{city:12s} {temp:5.1f}°C wind {wind:.1f} km/h")
asyncio.run(main())
Sequentially, this would take ~5 seconds. With async gather + one shared session, it's ~1 second — bounded by the single slowest call, not their sum. Add 50 cities and it barely gets slower.
Limiting Concurrency — Don't DDoS Your Own API
Firing 10,000 requests at once is easy in async — and a great way to get rate-limited or
crash the server. Use asyncio.Semaphore to cap in-flight tasks.
import asyncio, aiohttp
async def fetch_one(sem, session, url):
async with sem: # at most N in flight
async with session.get(url) as r:
return await r.text()
async def fetch_many(urls, max_concurrent=10):
sem = asyncio.Semaphore(max_concurrent)
async with aiohttp.ClientSession() as session:
return await asyncio.gather(
*[fetch_one(sem, session, u) for u in urls]
)
# Even if urls has 10,000 entries, only 10 requests run at any moment
urls = [f"https://httpbin.org/anything/{i}" for i in range(10_000)]
results = asyncio.run(fetch_many(urls, max_concurrent=20))
The async with sem: block acquires one slot; releasing it happens automatically when the block exits. This is how you stay a good API citizen at scale.
When to Use async / await
multiprocessing or joblib.requests is fine.to_thread().Modern Pattern — asyncio.TaskGroup (Python 3.11+)
TaskGroup is the recommended replacement for gather when you want
clean error handling: if any child task fails, the group cancels the rest and raises
a single grouped exception.
import asyncio
async def work(name, delay, fail=False):
await asyncio.sleep(delay)
if fail:
raise RuntimeError(f"{name} exploded")
return f"{name} ok"
async def main():
try:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(work("A", 1))
t2 = tg.create_task(work("B", 2, fail=True))
t3 = tg.create_task(work("C", 3))
except* RuntimeError as eg: # exception group syntax
for e in eg.exceptions:
print(f"caught: {e}")
asyncio.run(main())
Golden Rules
asyncio.run(main()) exactly once at the top of your
program. Never nest it, never call it inside another coroutine.
async def.
No time.sleep, no requests.get, no long pandas ops. If you must,
wrap it: await asyncio.to_thread(blocking_fn, args).
asyncio.gather() to run known coroutines concurrently.
Use asyncio.create_task() to fire-and-continue.
Prefer asyncio.TaskGroup on Python 3.11+ for clean error handling.
aiohttp.ClientSession
(or httpx.AsyncClient) per program, not per request. Losing connection
pooling erases most of the speedup.
asyncio.timeout(seconds) is your friend.
Semaphore.
Async makes DDoSing your own API effortless — set a limit like 20–50 in-flight requests.
create_task().
Unreferenced tasks can be garbage-collected mid-run and silently disappear.
multiprocessing or ProcessPoolExecutor — async
won't help there, it's still one thread.