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#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import time

def fetch(name, seconds):
    print(f"{name} start")
    time.sleep(seconds)              # simulate waiting on the network
    print(f"{name} done")
    return name

t0 = time.time()
results = [fetch("A", 3), fetch("B", 2)]
print(results)
print(time.time() - t0)              # ≈ 5 seconds

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#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import asyncio, time

async def fetch(name, seconds):
    print(f"{name} start")
    await asyncio.sleep(seconds)     # simulate waiting on the network
    print(f"{name} done")
    return name

async def main():
    results = await asyncio.gather(
        fetch("A", 3),
        fetch("B", 2),
    )
    print(results)

t0 = time.time()
asyncio.run(main())
print(time.time() - t0)              # ≈ 3 seconds

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#

VersionPatternTotal timeKey difference
Synctime.sleep + sequential calls3+2 = 5 sOne finishes before the next starts
Asyncasyncio.sleep + asyncio.gather≈ 3 sBoth 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 loop

The 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 / coroutinemain / A / B on the right; a Task is “a coroutine sitting on the event loop’s queue” (next section). gather wraps 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. await waiting 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:

  1. Creates a new event loop
  2. Wraps coro as the main task and queues it
  3. Runs the event loop until the main task finishes
  4. Closes the event loop
1
2
3
4
async def main():
    print("hello")

asyncio.run(main())

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:

1
2
3
4
5
6
7
8
def normal():
    step_1()
    step_2()           # always runs right after step_1, no chance to yield CPU

async def coro():
    step_1()
    await something()  # pauses here, CPU goes to another coroutine
    step_2()           # resumes here once others have run and our wait is over

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 the await section)
1
2
3
4
5
6
7
8
async def hello():  # declare a coroutine function
    print("hi")

print(hello)        # <function hello at 0x...>      ← coroutine function (not awaitable)
coro = hello()      # ← coroutine object (this one is awaitable)
print(type(coro))   #   <class 'coroutine'>
print(coro)         #   <coroutine object hello at 0x...>
# Note: the print("hi") inside the function has not run yet!

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): 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
async def worker(i):
    await asyncio.sleep(1)
    print(f"task {i} done")

async def main():
    tasks = [asyncio.create_task(worker(i)) for i in range(5)]
    # do other stuff here
    await asyncio.gather(*tasks)    # await them all together at the end

asyncio.run(main())

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async def fetch(i):
    await asyncio.sleep(1)
    return i * 2

async def main():
    results = await asyncio.gather(
        fetch(1), fetch(2), fetch(3), fetch(4), fetch(5)
    )
    print(results)        # [2, 4, 6, 8, 10]

asyncio.run(main())

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:

TypeHow it’s createdRole
Coroutine (object)Calling an async def functionRepresents “the function’s execution logic” itself; has to be started to run. await on it does not yield control
Taskasyncio.create_task(coro), or auto-created by asyncio.gather(*coros)A coroutine bound to the event loop; await on it does yield control
Futureasyncio.Future() or low-level APIsA 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import asyncio

async def coro_a():
    print("A")

async def coro_b():
    print("B (hopefully nobody is hogging the event loop...)")

async def main():
    task_b = asyncio.create_task(coro_b())  # wrap as Task, add to event loop queue
    for _ in range(3):
        await coro_a()                      # await a coroutine — does not yield control!
    await task_b                            # await a Task — yields control

asyncio.run(main())

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
A

This 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:

SyntaxSync counterpartWhen to use
async withwithEntering / exiting the context itself does IO (opening an HTTP session, connecting to a database)
async forforIterating does IO each time you fetch the next item (reading rows one by one from a database cursor)
1
2
3
4
5
6
7
# Async context manager: entering and exiting may both wait on IO
async with aiohttp.ClientSession() as session:
    ...

# Async iterator: fetching the next item may wait on IO
async for row in cursor:
    print(row)

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):

1
pip install aiohttp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import asyncio
import aiohttp
import time

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = [
        "https://example.com",
        "https://www.python.org",
        "https://httpbin.org/get",
    ]
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
        for url, html in zip(urls, results):
            print(f"{url}{len(html)} chars")

t0 = time.time()
asyncio.run(main())
print(f"Total time: {time.time() - t0:.2f} s")

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:

1
2
3
4
5
6
7
8
9
async def slow_task():
    await asyncio.sleep(10)        # imagine this is some slow IO
    return "done"

async def main():
    try:
        result = await asyncio.wait_for(slow_task(), timeout=3.0)
    except TimeoutError:
        print("Over 3 seconds, giving up")

gather accepts return_exceptions=True so that one failing task doesn’t drag down the rest:

1
2
3
4
5
6
7
8
9
results = await asyncio.gather(
    fetch(1), fetch(2), fetch(3),
    return_exceptions=True     # failures return the exception object instead of raising
)
for r in results:
    if isinstance(r, Exception):
        print(f"Task failed: {r}")
    else:
        print(f"Result: {r}")

When to use async#

ScenarioGood 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#

ApproachWhen to useCharacteristics
asyncioIO-bound, lots of waitingSingle-threaded, requires async libraries
threadingIO-bound but you want to keep using sync librariesLimited by the GIL, switching has overhead
multiprocessingCPU-bound computationBypasses the GIL, no shared memory, slow startup

Simple mantra: “wait” → async, “compute” → multiprocessing.


Common mistakes#

Forgetting to await a coroutine object#

1
2
3
async def main():
    asyncio.sleep(3)        # Wrong! The coroutine object is discarded; nothing inside runs
    await asyncio.sleep(3)  # Correct

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#

1
await time.sleep(3)   # TypeError: object NoneType can't be used in 'await' expression

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#

1
2
3
async def bad():
    time.sleep(3)              # No error, but the entire event loop freezes
    requests.get(url)          # Same

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#

1
await asyncio.sleep(1)         # SyntaxError: await is only allowed inside 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.

1
2
# Jupyter cell
await main()            # no asyncio.run

Cheat sheet#

SyntaxTypeRole
async defKeywordDeclares a coroutine function; calling it doesn’t execute the body, just returns a coroutine object
await exprKeywordWaits for expr (an awaitable) to finish; only usable inside async def
asyncio.run(coro)Module functionProgram entry point; creates the event loop, runs coro, closes when done
asyncio.gather(*coros)Module functionWraps multiple coroutines as Tasks, queues them in parallel and waits, returns a list
asyncio.create_task(coro)Module functionWraps a single coroutine as a Task and queues it
asyncio.sleep(s)Module functionReturns 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:

  1. async def declares a coroutine, await waits for an “awaitable,” asyncio.run starts the event loop
  2. await is the verb, awaitable is the object being waited on — you need both
  3. “Wait” → async, “compute” → multiprocessing