Skip to content

Asyncio in Python

asyncio is Python’s built-in library for asynchronous I/O.

It’s designed for workloads that spend lots of time waiting:

  • network requests
  • database calls
  • file I/O (through async libraries)
  • asyncio: single-threaded concurrency using an event loop (great for many I/O tasks)
  • threading: multiple threads (good for I/O, simpler when using blocking libs)
  • multiprocessing: multiple processes (good for CPU-bound)
  • async def defines a coroutine.
  • await pauses the coroutine so other tasks can run.
  • The event loop schedules coroutines and resumes them when they’re ready.
first_coroutine.py
import asyncio
 
 
async def main():
    print("Hello")
    await asyncio.sleep(1)
    print("World")
 
 
asyncio.run(main())

If you do 100 HTTP requests sequentially, you wait 100 times.

With asyncio you can:

  • start many requests
  • efficiently wait for them concurrently

For CPU-heavy loops, asyncio won’t speed things up.

Use:

  • multiprocessing
  • numpy/vectorization
  • compiled extensions

This is the single most common misreading of asyncio. await means wait here until this finishes. Written in a loop, it is exactly as sequential as ordinary code:

diagram Diagram mermaid

Measured, five coroutines each sleeping 0.2 s:

timespeedup
for i in range(5): await work(i)1.005 s1.0×
await asyncio.gather(*[work(i) for i in range(5)])0.215 s4.7×

Concurrency comes from handing the loop several things at once — gather, create_task, or a TaskGroup. An await on its own just yields control until that one operation is done.

not_running.py
import asyncio, inspect
 
async def work(name):
    await asyncio.sleep(0.1)
    return name
 
c = work("a")                     # no code has run yet
print(type(c).__name__)           # coroutine
print(inspect.iscoroutine(c))     # True
print(asyncio.run(c))             # 'a'   <- now it runs

A coroutine object is a description of work. Forgetting to await one is why RuntimeWarning: coroutine ... was never awaited exists — the call looked like it did something and did not.

create_task schedules, a bare coroutine waits to be asked

Section titled “create_task schedules, a bare coroutine waits to be asked”
task_vs_coro.py
log = []
 
async def note(n):
    log.append(f"{n} started")
    await asyncio.sleep(0.05)
 
t1 = asyncio.create_task(note("task"))   # handed to the loop right away
co = note("coro")                        # just an object; nothing scheduled
await asyncio.sleep(0.01)
print(log)                               # ['task started']   <- only one of them began

One thread, one loop. A coroutine runs until it hits an await, hands control back, and the loop picks whoever is ready. Nothing here is parallel — it is interleaved, and that is enough when the work is waiting.

sketch How the event loop interleaves coroutines p5.js
Each coroutine runs until it awaits, then yields to the loop. With gather, three coroutines overlap their waiting; awaited one by one they cannot.

Because there is only one thread, any function that blocks without awaiting stops the whole loop — every other coroutine included:

blocking.py
async def good():
    await asyncio.sleep(0.3)      # yields to the loop
 
async def blocker():
    time.sleep(0.3)               # WRONG inside async: nothing yields
two coroutines running togethermeasured
both await asyncio.sleep(0.3)0.310 s — overlapped
both time.sleep(0.3)0.602 s — serialized
order.py
res = await asyncio.gather(timed("slow", 0.3), timed("fast", 0.05), timed("mid", 0.15))
print(res)              # ['slow', 'fast', 'mid']   <- the order you passed them
# actual completion order was ['fast', 'mid', 'slow']

Results line up with your arguments, which makes zip(urls, results) safe. If you want results as they arrive, use asyncio.as_completed.

errors.py
await asyncio.gather(work("ok"), bad())
# ValueError: boom      <- the first exception propagates immediately
 
await asyncio.gather(work("ok"), bad(), return_exceptions=True)
# ['ok', ValueError('boom')]    <- collected instead of raised

With return_exceptions=True you always get one entry per input, and it is your job to check which entries are exceptions.

pch.quizTag pch.quizDefaultTitle
  1. Five coroutines each sleep 0.2 s. Awaiting them one per loop iteration took 1.005 s; gather took 0.215 s. What does that show?

    pch.quizShowAnswer

    B — await means 'wait here', so concurrency comes from handing several coroutines to the loop at once — A loop of awaits is sequential by definition. gather submits all five, so their waiting overlaps. Everything still runs on one thread.

  2. You write c = work('a') where work is an async def, and never await c. What has happened?

    pch.quizShowAnswer

    C — nothing ran; c is a coroutine object and Python warns it was never awaited — Calling an async function builds a coroutine object describing the work. inspect.iscoroutine(c) is True and no body has executed until it is awaited or scheduled.

  3. Two coroutines that both call time.sleep(0.3) took 0.602 s, while two that await asyncio.sleep(0.3) took 0.310 s. Why?

    pch.quizShowAnswer

    B — time.sleep blocks the single thread the loop runs on, so nothing else can proceed — asyncio is single-threaded. A blocking call never yields control, so the loop cannot switch to the other coroutine. Use asyncio.to_thread for work you cannot make awaitable.

  4. gather is given slow (0.3 s), fast (0.05 s) and mid (0.15 s), in that order. What does it return?

    pch.quizShowAnswer

    B — results in argument order: slow, fast, mid — gather preserves argument order regardless of when each finished, which is what makes zip(inputs, results) correct. Use asyncio.as_completed if you want them as they arrive.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading