What async actually is, why it exists, and how the machinery works โ before you touch a single pattern.
Most real programs spend their lives waiting โ for a network reply, a database query, a file read, an API call. The CPU isn't busy during that wait. It's just... standing there.
You could spin up 3 OS threads. It works, but threads are heavy: each costs memory, the OS schedules them preemptively, and shared state invites race conditions and locks. Async gets the same overlap for I/O work โ on one thread, cooperatively, without locks.
A single cook can start the rice boiling, and while it boils chop vegetables, and while those roast plate a salad. One person, never idle, many dishes progressing at once. That's async: one worker that switches tasks whenever the current one hits a wait.
This trips everyone up. They are not the same thing.
await. That's
concurrency, not parallelism.
At the heart of asyncio is the event loop โ a scheduler that runs your coroutines.
Each coroutine runs until it hits an await on something that isn't ready yet (a network
reply). At that point it yields control back to the loop, which picks another ready
task to run. When the awaited thing completes, the loop resumes the paused coroutine where it left off.
await.
Nothing interrupts you mid-function. That's why there are no locks โ but also why one greedy task that
never awaits will freeze the whole loop.
async and await
async def defines a coroutine function. Calling it does not run
it โ it hands you a coroutine object. Nothing happens until it's awaited or scheduled on the loop.
async def fetch(url):
...
coro = fetch("https://example.com") # โ nothing ran yet!
print(coro) # <coroutine object fetch at 0x...>
await means "pause me until this is done"
await can only appear inside an async def. It runs an awaitable and, if it
isn't ready, suspends the current coroutine so the loop can do other work.
import asyncio
async def main():
print("start")
await asyncio.sleep(1) # โ yields to the loop for 1s, doesn't block the CPU
print("done, 1s later")
asyncio.run(main()) # โ starts the event loop and runs main() to completion
fetch(url) on its own runs nothing and Python warns
"coroutine was never awaited". You need await fetch(url).
gatherHere's the subtlety: awaiting coroutines one after another is still sequential. To get overlap you must schedule them on the loop together, then await them.
import asyncio
async def fetch(name, seconds):
print(f"{name} started")
await asyncio.sleep(seconds) # simulate a slow network call
print(f"{name} done")
return name
async def main():
# โ Sequential: 3s total โ each await finishes before the next starts
await fetch("A", 1)
await fetch("B", 1)
await fetch("C", 1)
# โ
Concurrent: ~1s total โ all three run while each other waits
results = await asyncio.gather(
fetch("A", 1),
fetch("B", 1),
fetch("C", 1),
)
print(results) # ['A', 'B', 'C']
asyncio.run(main())
await coro() โ run one, wait for it (sequential).asyncio.gather(a, b, c) โ run many concurrently, get all results back.asyncio.create_task(coro()) โ schedule now, await later (fire-and-track).Async is a scalpel, not a hammer. It only helps a specific kind of work.
| Workload | Example | Use async? |
|---|---|---|
| I/O-bound (waiting on the outside world) | HTTP requests, DB queries, reading files, sockets | โ Yes โ this is exactly what it's for |
| CPU-bound (crunching numbers) | Image resizing, math, parsing huge data, ML | โ No โ use processes (it will just block the loop) |
| A single, simple script | One request, then exit | ๐คท No need โ sync is simpler |
await,
so it never yields โ the single loop is stuck, and every other task starves. For that, offload to a
process (see Pattern 4).
await โ the coroutine silently never runs.time.sleep, requests.get, heavy CPU) inside async โ it freezes the entire loop. Use asyncio.sleep and async libraries (httpx, aiohttp).gather or tasks.await from normal code; you enter async through asyncio.run().
Async lets a single thread juggle many I/O-bound tasks by switching
between them whenever one is waiting. You write async def coroutines,
pause them with await, and an event loop schedules whichever task is
ready. Run things together with gather or create_task to make the waits
overlap. It's not faster CPU โ it's never sitting idle.
That's the whole idea. Every pattern in this collection is just a disciplined way of applying it.