Asynchronous Programming with async / await#
async and await are syntax introduced in Python 3.5 for writing asynchronous programs. The core idea: when your program has to wait for an external event (network, disk, database), don’t sit there idle — let other tasks use the CPU instead.
Why async#
Here is a minimal comparison between sync and async — two tasks A / B that need to wait 3 and 2 seconds respectively (simulating network or disk IO).
Synchronous version#
| |
Each fetch() has to finish before the next one can start, and the CPU is fully idle in between. Total time is 3+2 = 5 seconds, added linearly.
Asynchronous version#
| |
The two tasks are dispatched almost simultaneously. Whichever finishes waiting first finishes first, and total time is close to “the slowest one” (A’s 3 seconds) rather than the sum.
Output:
A start
B start
B done
A done
['A', 'B']Both start lines are printed in the same instant — proof that the tasks are waiting in parallel, not running in queue. Then they finish in order of wait time, shortest first.
Comparison#
| Version | Pattern | Total time | Key difference |
|---|---|---|---|
| Sync | time.sleep + sequential calls | 3+2 = 5 s | One finishes before the next starts |
| Async | asyncio.sleep + asyncio.gather | ≈ 3 s | Both wait at the same time; whichever finishes first finishes first |
Next, let’s look at the whole async flow with a sequence diagram, then break down each component in turn.
Sequence diagram: coroutines and the event loop#
The full picture of an async program can be captured in a sequence diagram. The diagram below uses the previous example (main runs A / B in parallel via gather) to show how the event loop and coroutines interact:
Event loop Coroutines
│
│ ◀── ① asyncio.run(main())
│ create a new event loop, wrap main as a Task and add to the queue
│
│ ── ② start the event loop, run the main task ─▶ main: running
│ │
│ await asyncio.gather(fetch("A", 3), fetch("B", 2))
│ (gather wraps both as Tasks and adds them to the queue)
│ ◀── ③ hand control back to the event loop ─────
│
│ ── ④ pick A from the queue and run it ──────▶ A: running
│ │
│ await asyncio.sleep(3)
│ ◀── ⑤ wait on sleep, hand control back ─────────
│
│ ── ⑥ pick B from the queue and run it ──────▶ B: running
│ │
│ await asyncio.sleep(2)
│ ◀── ⑦ wait on sleep, hand control back ─────────
│
│ ** all three tasks are waiting: event loop is idle, waiting for the earliest timer **
│
│ ── ⑧ B's sleep(2) expires, wake B ─────────▶ B: resume after await → return
│
│ ── ⑨ A's sleep(3) expires, wake A ─────────▶ A: resume after await → return
│
│ A and B done, gather releases main's wait
│ ── ⑩ wake main ────────────────────────────▶ main: resume after await → return (main() ends)
│
│ ── ⑪ asyncio.run(main()) shuts down the event loopThe diagram has three kinds of components, broken down in the next three sections:
- Event loop — the timeline on the left; started by
asyncio.run(step ①) - Task / coroutine —
main/A/Bon the right; a Task is “a coroutine sitting on the event loop’s queue” (next section).gatherwraps coroutines into Tasks and adds them to the queue (③) await— the moment a coroutine voluntarily hands control back to the event loop (⑤, ⑦)
The other steps (②, ④, ⑥, ⑧, ⑨, ⑩, ⑪) are internal switches handled automatically by the event loop — it only picks coroutines that are ready (their wait condition has been met), and idles until the next deadline if none are ready. When the queue is empty, asyncio.run cleans up and closes the loop.
Event loop#
The event loop is the heart of asyncio. The official docs describe it as a conductor — the conductor doesn’t play any instrument, but decides who plays next and when to switch.
How the event loop works:
- It maintains a collection of jobs (a to-do list)
- It picks one coroutine from the list and hands control to it
- When the coroutine reaches a pause point (e.g.
awaitwaiting on IO) or finishes, control returns to the event loop - The event loop then picks the next ready coroutine from the list
- When the list is empty, the event loop rests rather than spinning the CPU
This is cooperative multitasking: every coroutine has to “politely yield” — it must not hog the event loop, or other coroutines will be starved. This is different from the OS’s preemptive multitasking, where the OS forces switches; here, coroutines yield voluntarily.
One more thing worth emphasizing: the whole thing runs on a single thread on a single CPU core. It’s not multi-threading, and it’s not multi-core parallelism. The efficiency comes purely from “not idling while waiting” — which is why async fits IO-bound work (waiting on network or disk) but not CPU-bound work (computation itself has no waiting time to give up).
asyncio.run(coro): the entry point that starts the event loop#
The event loop doesn’t run by itself — it has to be started by asyncio.run, corresponding to step ① in the sequence diagram.
asyncio.run(coro) does four things:
- Creates a new event loop
- Wraps
coroas the main task and queues it - Runs the event loop until the main task finishes
- Closes the event loop
| |
The whole program calls asyncio.run exactly once, as the entry point into the async world.
Coroutines and Tasks#
Coroutine functions and coroutine objects#
A coroutine function is a function that can pause and resume. A regular function, once called, runs from start to finish without interruption; a coroutine can pause at a certain point, “let someone else run,” and then resume from where it paused:
| |
This “pause-and-resume” capability is what makes asynchronous behavior possible.
- Declare a coroutine function with
async def - Calling a coroutine function does not run its body — it returns a coroutine object, which has to be added to the event loop’s queue to actually run.
- A coroutine object is awaitable — meant to be placed after
await(covered in theawaitsection)
| |
Tasks: coroutines bound to the event loop#
The official docs define a Task as:
Roughly speaking, tasks are coroutines (not coroutine functions) tied to an event loop.
In other words, a Task is a coroutine that has been bound to the event loop and put on the queue — a coroutine object on its own is just function logic; only after being wrapped as a Task does the event loop actually schedule and run it.
There are two ways to create a Task:
asyncio.create_task(coro)— wraps a single coroutine as a Taskasyncio.gather(*coros)— wraps multiple coroutines as Tasks and waits for all of them to finish
asyncio.create_task(coro): launch a background task#
Good for “spawning new tasks on the fly” — it queues the coroutine immediately on call and returns a Task object you can await later:
| |
asyncio.gather(*coros): run multiple coroutines in parallel#
gather automatically wraps multiple coroutines as Tasks and queues them, returning an awaitable that has to be paired with await to actually wait for all of them to finish:
| |
Each of the five fetch() calls waits 1 second, but because they are wrapped as Tasks and queued together, the event loop interleaves them — the whole thing only takes about 1 second.
await#
This corresponds to the moments in the sequence diagram where a coroutine voluntarily “yields control” (steps ③, ⑤, ⑦). The role of await <awaitable> is to hand control back to the event loop so the loop can use the gap to run other coroutines.
Awaitable objects#
await must be followed by an awaitable — there are three kinds:
| Type | How it’s created | Role |
|---|---|---|
| Coroutine (object) | Calling an async def function | Represents “the function’s execution logic” itself; has to be started to run. await on it does not yield control |
| Task | asyncio.create_task(coro), or auto-created by asyncio.gather(*coros) | A coroutine bound to the event loop; await on it does yield control |
| Future | asyncio.Future() or low-level APIs | A container that represents “the state and result of some computation.” The official docs use the analogy of a three-color traffic light (red / yellow / green) for Future’s three states (pending / cancelled / done). Task is actually a subclass of Future — rarely used directly, listed here for reference |
await on a coroutine vs await on a Task#
There is a trap that’s easy to fall into: await on a coroutine object does not actually yield control to the event loop — the effect is almost the same as calling a regular synchronous function.
What actually yields control is await on a Task. A side-by-side example:
| |
Output:
A
A
A
B (hopefully nobody is hogging the event loop...)coro_a runs three times in a row before coro_b gets its turn — because await coro_a() does not hand control back to the event loop, so task_b never gets picked.
Change await coro_a() to await asyncio.create_task(coro_a()) (wrap as a Task before awaiting), and the output becomes:
B (hopefully nobody is hogging the event loop...)
A
A
AThis explains why parallelism relies on asyncio.gather or asyncio.create_task — they wrap coroutines as Tasks (coroutines bound to the event loop), and only then does await on those Tasks actually yield control, letting the event loop interleave other tasks.
asyncio.sleep(delay)#
asyncio.sleep(delay) is the async version of sleep provided by asyncio. Its job is to pause the current coroutine for delay seconds, often used to simulate waiting on network or disk IO. Unlike time.sleep, it hands control back to the event loop while waiting, so other tasks have a chance to run.
You might be confused here: asyncio.sleep is a Coroutine (calling it returns a coroutine object, not a Task), so by the rule above, await on it shouldn’t yield control, right?
But per the official docs:
sleep() always suspends the current task, allowing other tasks to run.
So await asyncio.sleep() does still yield control — this is achieved by the implementation inside the asyncio.sleep() coroutine.
async for / async with#
Besides async def and await, Python has two more async variants you’ll run into in practice — worth knowing before we move on to the aiohttp hands-on section:
| Syntax | Sync counterpart | When to use |
|---|---|---|
async with | with | Entering / exiting the context itself does IO (opening an HTTP session, connecting to a database) |
async for | for | Iterating does IO each time you fetch the next item (reading rows one by one from a database cursor) |
| |
The rule is the same as await: only usable inside async def, and the underlying implementation yields the event loop to other tasks while waiting. The next section on aiohttp will use async with heavily.
Hands-on: fetching multiple pages in parallel#
The sync version uses requests; the async version needs aiohttp (because requests itself doesn’t support async):
| |
| |
Apply the same logic to 100 URLs — the sync version may take tens of seconds, the async version only 1–2.
Timeouts and cancellation#
Use asyncio.wait_for to set a timeout:
| |
gather accepts return_exceptions=True so that one failing task doesn’t drag down the rest:
| |
When to use async#
| Scenario | Good fit for async? |
|---|---|
| A scraper hitting hundreds of pages at once | ✅ Excellent fit |
| Web backends (FastAPI, aiohttp server) | ✅ Good — high-concurrency connections |
| Real-time chat, WebSocket | ✅ Good fit |
| Lots of database queries (asyncpg, motor) | ✅ Good fit |
| Image processing, matrix math, compression / decompression | ❌ Use multiprocessing |
| One-off small scripts with little waiting | ❌ Just use sync, don’t make life hard |
Trying to speed up an existing sync library (like requests) | ❌ Can’t be done — switch to aiohttp or httpx |
How it splits work with threading and multiprocessing#
| Approach | When to use | Characteristics |
|---|---|---|
asyncio | IO-bound, lots of waiting | Single-threaded, requires async libraries |
threading | IO-bound but you want to keep using sync libraries | Limited by the GIL, switching has overhead |
multiprocessing | CPU-bound computation | Bypasses the GIL, no shared memory, slow startup |
Simple mantra: “wait” → async, “compute” → multiprocessing.
Common mistakes#
Forgetting to await a coroutine object#
| |
Python will warn coroutine 'sleep' was never awaited — meaning “you created a coroutine but never handed it to the event loop.”
Trying to await the return value of a sync function#
| |
The opposite of the previous mistake: time.sleep() is a regular function that returns None, and None isn’t an awaitable type. Only coroutine objects, Tasks, and Futures can be awaited.
Using a sync blocking function inside an async function#
| |
It doesn’t raise an error, but all other coroutines are blocked, defeating the point of async. Use async alternatives like asyncio.sleep, aiohttp, httpx.AsyncClient instead.
Using await outside an async def#
| |
Calling asyncio.run inside Jupyter#
Jupyter is already running an event loop, so calling asyncio.run() directly will error. The fix: just await directly.
| |
Cheat sheet#
| Syntax | Type | Role |
|---|---|---|
async def | Keyword | Declares a coroutine function; calling it doesn’t execute the body, just returns a coroutine object |
await expr | Keyword | Waits for expr (an awaitable) to finish; only usable inside async def |
asyncio.run(coro) | Module function | Program entry point; creates the event loop, runs coro, closes when done |
asyncio.gather(*coros) | Module function | Wraps multiple coroutines as Tasks, queues them in parallel and waits, returns a list |
asyncio.create_task(coro) | Module function | Wraps a single coroutine as a Task and queues it |
asyncio.sleep(s) | Module function | Returns a coroutine object that waits s seconds; needs await to actually run |
Summary#
Back to the minimal example at the start — the sync version takes 5 seconds for 2 tasks; the async version cuts it down to 3 seconds (the gap grows as the number of tasks grows; see “Hands-on: fetching multiple pages in parallel”). The CPU didn’t get faster — we just stopped “sitting idle for every IO.” The waiting times got overlapped.
Three sentences to remember this chapter:
async defdeclares a coroutine,awaitwaits for an “awaitable,”asyncio.runstarts the event loopawaitis the verb, awaitable is the object being waited on — you need both- “Wait” →
async, “compute” →multiprocessing