asyncio basics
asyncio lets a single thread juggle many tasks that spend most of their time *waiting* — for a network reply, a disk read, a timer. Instead of blocking the whole program while one call waits, an async function can pause itself and let other work run in the meantime.
async def defines a coroutine — a function that *can* pause. Calling it doesn't run it; it returns a coroutine object. You drive it with await, which says "pause me here until this finishes, and let other tasks use the time". At the top level, asyncio.run(coro()) starts the event loop, runs the coroutine to completion, and returns its result.
To run several coroutines concurrently, hand them to asyncio.gather(*coros) and await it — it schedules them all together and returns a list of their results, in the order you passed them in. That's where the speed-up comes from: while one task waits, others make progress.
The crucial mental model: async is concurrency, not parallelism. It's still one thread — nothing runs at literally the same instant. It wins for I/O-bound work (lots of waiting) by overlapping the waits. For CPU-bound work (heavy computation, no waiting), async buys you nothing — use processes or threads for that.
Syntax
import asyncio
async def fetch(name): # a coroutine
await asyncio.sleep(0) # pause point — yields control
return name
asyncio.run(fetch("x")) # run one coroutine to completion
# Run many concurrently and collect results (order preserved):
async def main():
return await asyncio.gather(fetch("a"), fetch("b"))
asyncio.run(main())Worked examples
A coroutine, awaited and run
import asyncio
async def greet(name):
await asyncio.sleep(0) # yield control (no real delay)
return f"Hello, {name}!"
print(asyncio.run(greet("Riley")))
# Output:
# Hello, Riley!asyncio.gather runs coroutines concurrently, preserves order
import asyncio
async def greet(name):
await asyncio.sleep(0)
return f"Hello, {name}!"
async def fetch_all(names):
# *(...) unpacks the generator into positional args for gather
return await asyncio.gather(*(greet(n) for n in names))
print(asyncio.run(fetch_all(["Alice", "Bob"])))
# Output:
# ['Hello, Alice!', 'Hello, Bob!']Applying an async function to many values at once
import asyncio
async def double(x):
await asyncio.sleep(0)
return x * 2
async def run_all(values):
return await asyncio.gather(*(double(v) for v in values))
print(asyncio.run(run_all([1, 2, 3])))
# Output:
# [2, 4, 6]Common mistakes & gotchas
Calling a coroutine without awaiting it does nothing
greet("x") on its own just creates a coroutine object — the body never runs, and Python warns coroutine '...' was never awaited. You must await it (inside another async function) or hand it to asyncio.run / asyncio.gather.
async is not parallelism
Everything runs on one thread, cooperatively. A coroutine only yields control at an await. A long CPU-bound loop with no await will block the entire event loop — async gives zero speed-up there. Reach for async only when the bottleneck is waiting (I/O), not computing.
Don't call asyncio.run() inside a running loop
asyncio.run() starts a fresh event loop, so calling it from code that's already inside one (e.g. nested in another coroutine) raises RuntimeError: asyncio.run() cannot be called from a running event loop. Inside async code, await the coroutine directly instead.
Why it matters
Modern backends are mostly waiting — on databases, APIs, and other services. asyncio lets one process handle thousands of in-flight requests by overlapping all that waiting, which is why async web frameworks (FastAPI, aiohttp) and async database drivers dominate high-throughput Python. Knowing when async helps — and when it doesn't — is a senior judgement call.