Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 15 – Async programming with asyncio

Async Python: the event loop mental model

Learn what async def, await and the asyncio event loop do, why one blocking call stalls every task, and which problems async code solves well.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain what a coroutine is and when its body runs
  • Trace how the event loop switches between tasks at each await
  • Show why one blocking call or busy loop stalls every task on the loop
  • Recognise a forgotten await from the warning Python prints
  • Identify the problems async code solves well and the ones it does not

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain what a coroutine is and when its body runs
  • Trace how the event loop switches between tasks at each await
  • Show why one blocking call or busy loop stalls every task on the loop
  • Recognise a forgotten await from the warning Python prints
  • Identify the problems async code solves well and the ones it does not

Before you start

On this page

A thousand replies of 0.2 seconds each take 200 seconds when a program waits for them one after another. asyncio waits for all of them at once, in one thread, and the run measured below finishes in about 0.2 seconds. It does that by letting tasks take turns: a task runs until it reaches an await on something that is not ready yet, such as a reply from a server, and while it waits the event loop: The part of asyncio that runs tasks one at a time in one thread. It runs a task until it awaits something that is not ready, runs other ready tasks meanwhile, and resumes the first once its timer fires or its data arrives. runs another task that is ready.

Nothing else runs between two awaits of a task, so the switching is cooperative scheduling: Taking turns where each task decides when to give up control. An asyncio task runs until it awaits something that is not ready, and nothing switches it out before that, so a task that never awaits holds up all the others.: a task that computes for half a second, or calls a blocking function such as time.sleep(), holds up every other task for that half second. Below you will see when a coroutine’s body runs, step through the turns tasks take, watch a busy loop stall a timer, catch the forgotten await, and sort the jobs asyncio suits from the ones it does not.

A coroutine runs only when something drives it

greet() below is a coroutine function: A function defined with async def. Calling it runs none of its body: it returns a coroutine object, which runs only when something drives it, such as await, a task or asyncio.run()., a function defined with async def. Calling it is not running it. The call returns a coroutine: The object a call to an async def function returns. Driven by await, a task or asyncio.run(), it runs its body up to each await on something not ready, pauses there with its local variables kept and resumes later from the same spot. object, and the body starts only when an event loop runs it:

Calling greet() runs none of its body Python · coroutine_object.py
"""Calling an async def function runs none of its body: it makes a coroutine object."""
import asyncio
import inspect


async def greet(name):
    print(f"  greet({name!r}) starts")
    await asyncio.sleep(0)
    print(f"  greet({name!r}) carries on after its await")
    return f"hello, {name}"


coro = greet("Asha")
print("greet('Asha') returned a", type(coro).__name__)
print("its state:", inspect.getcoroutinestate(coro))

result = asyncio.run(coro)  # start an event loop, run the coroutine to its end, close the loop
print("asyncio.run() returned:", repr(result))
print("its state now:", inspect.getcoroutinestate(coro))

Output

greet('Asha') returned a coroutine
its state: CORO_CREATED
  greet('Asha') starts
  greet('Asha') carries on after its await
asyncio.run() returned: 'hello, Asha'
its state now: CORO_CLOSED

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 coroutine_object.py

greet("Asha") printed nothing: it returned a coroutine object in the state CORO_CREATED. asyncio.run() then did three things. It created an event loop, ran the coroutine until it returned, and closed the loop again, handing back the coroutine’s return value. The await asyncio.sleep(0) inside paused the coroutine for one turn of the loop and let it continue. Afterwards the coroutine is CORO_CLOSED, so it cannot run a second time. Step through the same run and watch which part runs the body:

Interactive · message by message Who runs the body of greet("Asha") Step through coroutine_object.py. The call makes an object; asyncio.run() runs the body. The state under greet() is the name inspect.getcoroutinestate() gives.
Who runs the body of greet("Asha"), step 7 of 7. Messages so far: Your code to asyncio.run(): asyncio.run(coro); asyncio.run() to greet("Asha"): run to the next pause; greet("Asha") to asyncio.run(): paused at its await; asyncio.run() to greet("Asha"): resume; greet("Asha") to asyncio.run(): return 'hello, Asha'; asyncio.run() to Your code: 'hello, Asha'.Your coderesult = 'hello, Asha'asyncio.run()loop closedgreet("Asha")CORO_CLOSEDasyncio.run(coro)run to the next pausepaused at its awaitresumereturn 'hello, Asha''hello, Asha'

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The call. coro = greet("Asha") creates a coroutine object and runs none of the body, so nothing from greet() is printed. Its state is CORO_CREATED.
  2. Handed to asyncio.run(). asyncio.run(coro) creates an event loop and gives it the coroutine, to run until it returns.
  3. The body starts. The loop starts the body, which is now CORO_RUNNING. It prints "greet('Asha') starts" and reaches await asyncio.sleep(0).
  4. One turn of the loop. await asyncio.sleep(0) pauses the coroutine for one turn and hands control back to the loop. Paused, it is CORO_SUSPENDED, with its local variables kept.
  5. Resumed. The loop has nothing else to run, so it resumes the body right after the await. It prints "carries on after its await" and reaches return.
  6. The return value. The body returns "hello, Asha" to the loop. The coroutine is finished, CORO_CLOSED, and it can never run again.
  7. Back in your code. asyncio.run() closes the loop and returns the coroutine's value, which the program prints on the line asyncio.run() returned 'hello, Asha'.

Call asyncio.run() once, at the top of a program, with the coroutine that does everything else; await itself is only allowed inside an async def. The browser runner of this site and the asyncio REPL, started with python -m asyncio, also accept await at the top level, which is handy for trying things out.

Checkpoint

Check: Calling makes a coroutine; driving runs it

  1. Question 1 of 2 coro = greet("Asha") has just run, and nothing else yet. What has the body of greet() printed so far?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing, because the call only created a coroutine object

    greet("Asha") returns a coroutine object and runs none of its body, so nothing is printed and its state is CORO_CREATED. The body starts when asyncio.run(coro) creates an event loop and runs the coroutine.

  2. Question 2 of 2 After result = asyncio.run(coro) has returned, the program calls asyncio.run(coro) again with the same object. What happens?

    Choose one answer.

    A hint

    Look at the state the program printed after the first run.

    Show the answer: Question 2 of 2

    Answer: RuntimeError: cannot reuse already awaited coroutine

    A coroutine object is used up by one run: after it returns it is CORO_CLOSED. Running it again raises RuntimeError: cannot reuse already awaited coroutine (CPython 3.14.8). Each call of greet("Asha") makes a fresh object, so call the function again to run the body again.

await pauses a coroutine and hands control to its driver

A coroutine can pause part-way through its body and carry on later from the same spot, with all its local variables, like the generators of an earlier lesson, which pause at yield. An event loop is code that resumes coroutines by calling their send() method. This program plays the event loop itself, without asyncio:

Driving a coroutine with send() Python · hand_driven.py
"""An event loop is code that calls send() on coroutines. Here we play the loop by hand."""
import types


@types.coroutine
def pause(reason):
    # A bare yield is how a coroutine tells whoever drives it: "I am waiting, run something else".
    yield reason


async def checkout():
    print("checkout: reserve the items")
    await pause("waiting for the payment service")
    print("checkout: payment done, send the receipt")
    await pause("waiting for the mail server")
    print("checkout: receipt sent")
    return "order 1042 complete"


coro = checkout()
step = 1
while True:
    try:
        why = coro.send(None)  # run the coroutine until it pauses
    except StopIteration as finished:  # the coroutine returned
        print(f"driver: finished after {step} sends, result {finished.value!r}")
        break
    print(f"driver: send #{step} came back with {why!r}")
    step += 1

Output

checkout: reserve the items
driver: send #1 came back with 'waiting for the payment service'
checkout: payment done, send the receipt
driver: send #2 came back with 'waiting for the mail server'
checkout: receipt sent
driver: finished after 3 sends, result 'order 1042 complete'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hand_driven.py

Each send(None) ran checkout() until the next pause and came back with the value the coroutine handed out. The third send() let checkout() run to its return, which ends a coroutine the way it ends a generator: with StopIteration carrying the result. Step through the three sends and watch where checkout() stops each time:

Interactive · message by message Three sends drive checkout() to its end Step through hand_driven.py. Each send(None) runs checkout() to its next pause, and what the pause hands out comes back to the driver.
Three sends drive checkout() to its end, step 7 of 7. Messages so far: The driver to checkout(): send(None) #1; checkout() to The driver: waiting for payment; The driver to checkout(): send(None) #2; checkout() to The driver: waiting for mail server; The driver to checkout(): send(None) #3; checkout() to The driver: StopIteration with value.The driverfinished after 3 sendscheckout()finishedsend(None) #1waiting for paymentsend(None) #2waiting for mail serversend(None) #3StopIteration with value

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Created. coro = checkout() creates the coroutine. None of its body has run, and the driver has sent nothing yet.
  2. Send #1. coro.send(None) starts the body. It prints "reserve the items" and reaches await pause("waiting for the payment service").
  3. The first pause. The bare yield inside pause() hands the reason out, and checkout() stops on its await with its place kept. send() returns the reason, and the driver prints it.
  4. Send #2. The second send(None) resumes checkout() right after its first await. It prints "payment done, send the receipt" and reaches the second pause.
  5. The second pause. This time checkout() hands out "waiting for the mail server". A real event loop would now run other coroutines until the mail server answers.
  6. Send #3. The third send(None) resumes the body after its second await. It prints "receipt sent" and reaches return "order 1042 complete".
  7. StopIteration. A coroutine that returns raises StopIteration in its driver, with the result in its value attribute. The driver catches it and prints that it finished after 3 sends.

A real loop does the same with thousands of coroutines. When one hands back “I am waiting for this socket” or “wake me in 0.2 seconds”, the loop notes what it waits for, asks the operating system to report when the socket has data, and moves on to the next coroutine. You never write this driver yourself, but it is the whole mental model in a few lines.

Key idea

A coroutine pauses only at an await that reaches something not ready; everything between two pauses runs without interruption, and whatever drives the coroutine decides when it carries on.

Checkpoint

Check: await pauses and hands control back

  1. Question 1 of 2 A coroutine awaits pause() four times and then returns. Driven by hand the way hand_driven.py drives checkout(), how many send(None) calls does it take until StopIteration?

    Type a number.

    A hint

    checkout() pauses twice and needed three sends.

    Show the answer: Question 1 of 2

    Answer: 5 sends

    Each send(None) runs the coroutine to its next pause. Four sends reach the four pauses, and the fifth runs from the last pause to the return, which raises StopIteration with the result. checkout() pauses twice, so it needed three.

  2. Question 2 of 2 Where can a running coroutine pause and let other code run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only at an await that reaches something that is not ready yet

    An await that reaches something not ready, such as a reply, a timer or another task, pauses the coroutine and hands control to whatever drives it. A loop then runs other coroutines and resumes this one later from the same spot, with its local variables kept.

The event loop lets tasks take turns at each await

An event loop repeats one cycle, drawn below: it takes a task that is ready, runs it until its next await, and parks it while it waits for something that is not ready yet.

The event loop takes a ready task, runs it until its next await, parks it while it waits, and queues it again when its wait is over.Ready queue:tasks that canrun nowRun one taskuntil itsnext awaitWaiting:timers, sockets,other tasksFinished:a result oran exceptiontake thenext oneawait sleep(0):back of the queueawait on somethingnot ready yettimer fires ordata arrivesreturnsor raises

The cycle an event loop repeats

Text description of the diagram

The diagram is a cycle of three boxes and one end point.

  • The ready queue holds the tasks that can run now. The loop takes the next one.
  • The loop runs that task until its next await. No other task runs during this step.
  • If the task awaits asyncio.sleep(0), it goes straight to the back of the ready queue.
  • If it awaits something that is not ready yet, such as a timer or a reply on a socket, it is parked with the waiting tasks.
  • When the timer fires or the data arrives, the waiting task goes back into the ready queue.
  • A task that returns or raises leaves the cycle with its result or its exception.

To run several coroutines at once, wrap each one in a asyncio task: A coroutine wrapped by asyncio.create_task() and scheduled on the running event loop. By default it starts the next time the code that is running pauses, runs alongside other tasks, and keeps the coroutine's result or exception for whoever awaits it. More in “Tasks, gather and running coroutines” with asyncio.create_task(). A task is scheduled at once, and the loop runs it as soon as the code that is running now pauses:

Two tasks take turns on one loop Python · taking_turns.py
"""Two tasks on one event loop: each runs until its next await, then the loop runs the other."""
import asyncio


async def quick_check(name):
    # Awaiting a coroutine that never waits is an ordinary call: no other task runs during it.
    return f"{name} is valid"


async def worker(name, steps):
    for i in range(1, steps + 1):
        print(f"{name}: step {i}, {await quick_check(name)}")
        await asyncio.sleep(0)  # a real pause: the loop may run another task now
    print(f"{name}: done")


async def main():
    upload = asyncio.create_task(worker("upload", 3))
    email = asyncio.create_task(worker("email", 2))
    print("main: two tasks created, neither has run yet")
    await upload
    await email
    print("main: both finished")


asyncio.run(main())

Output

main: two tasks created, neither has run yet
upload: step 1, upload is valid
email: step 1, email is valid
upload: step 2, upload is valid
email: step 2, email is valid
upload: step 3, upload is valid
email: done
upload: done
main: both finished

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 taking_turns.py

Creating the two tasks ran neither of them: main() kept going until its own await upload. Then the loop ran upload to its first await asyncio.sleep(0), then email to its first, then upload again, and so on, so the lines interleave. await asyncio.sleep(0) gives up the turn without waiting for any time to pass, which the asyncio documentation describes as the optimised way to let other tasks run. Step through the run on the cycle of the diagram; the sub-labels say which tasks sit in each box, in queue order:

Interactive · step by step upload and email take turns on one loop Step through taking_turns.py. The sub-labels say which tasks are in each box, in queue order; the arrow is the move of this step.
upload and email take turns on one loop, step 14 of 14. Showing Ready queue (nobody), Running now (nothing), Waiting (nobody), Finished (email, upload, main()).take the nextawait sleep(0)await, not readyits wait is overreturnsReady queuenobodyRunning nownothingWaitingnobodyFinishedemail, upload, main()main() returns

The drawing shows the whole scene; its 14 steps are listed below.

Every step as text
  1. main() runs. asyncio.run() runs main() as the first task. main() creates the upload and email tasks, which join the ready queue without running, and prints its first line.
  2. main() waits for upload. main() reaches await upload. upload has not finished, so main() is parked until it has, and the thread is free for the loop's next task.
  3. upload, step 1. The loop takes upload from the front of the queue. It prints step 1; await quick_check() returns at once, so upload runs on to await asyncio.sleep(0).
  4. The back of the queue. await asyncio.sleep(0) gives up the turn without waiting for any time to pass. upload goes to the back of the ready queue, behind email.
  5. email, step 1. email is at the front now. It prints its step 1 and runs to its own await asyncio.sleep(0).
  6. email gives up its turn. email goes to the back of the queue, behind upload. This is why the printed lines alternate between the two tasks.
  7. upload, step 2. upload takes its second turn. It prints step 2, reaches await asyncio.sleep(0) and goes to the back of the queue again.
  8. email, step 2. email takes its second turn the same way. It prints step 2 and goes behind upload.
  9. upload, step 3. upload prints step 3, the last of its three, and gives up its turn once more.
  10. email is done. email had two steps. Its loop ends, it prints "email: done" and returns, so it leaves the cycle for good.
  11. upload is done. upload's loop ends too. It prints "upload: done" and returns.
  12. main() is ready again. upload's end is what main() waited for, so the loop moves main() back into the ready queue.
  13. main() finishes. main() resumes after await upload. await email returns at once, because email has already finished, so main() prints "main: both finished" and returns.
  14. The loop is empty. Every task has finished and nothing waits, so asyncio.run() closes the loop. One thread ran all three tasks, one turn at a time.

Notice the other await in each line, await quick_check(name). It never caused a switch, because quick_check() returns without waiting for anything. Awaiting a coroutine is an ordinary call that happens to be allowed to pause. Only when something down the chain of calls waits for a result that is not ready does control go back to the loop.

Checkpoint

Check: Tasks take turns at each await

  1. Question 1 of 2 main() creates tasks for worker("a", 2), worker("b", 2) and worker("c", 2) of taking_turns.py, in that order, then awaits all three. Which three lines do the workers print first?

    Choose one answer.

    A hint

    Each worker gives up its turn at await asyncio.sleep(0), and the ready queue is served in order.

    Show the answer: Question 1 of 2

    Answer: a: step 1, then b: step 1, then c: step 1

    The queue starts as a, b, c. a prints step 1 and goes to the back (b, c, a), then b, then c, so the first three lines are each worker's step 1. On CPython 3.14.8 the full run prints a, b and c's step 1, then their step 2, then a, b and c done.

  2. Question 2 of 2 In taking_turns.py, why does await quick_check(name) never let the other task run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: quick_check() returns without waiting for anything, so the await is an ordinary call

    Awaiting a coroutine runs it as part of the current task. Control goes back to the loop only at an await on something that is not ready, so await quick_check(name) behaves like a plain call and await asyncio.sleep(0) is the only place where the two workers switch.

A thousand waits overlap in one thread

Waiting is where asyncio earns its keep. While a task waits, it costs no CPU time and holds no thread, so one loop can keep a great many waits in progress:

A thousand replies of 0.2 s each Python · thousand_waits.py
"""A thousand waits at once, in one thread, and the memory each waiting task holds."""
import asyncio
import threading
import time
import tracemalloc


async def wait_for_reply(request_id):
    await asyncio.sleep(0.2)  # stands in for a server that takes 0.2 s to answer
    return request_id


async def main():
    tracemalloc.start()
    before, _ = tracemalloc.get_traced_memory()
    start = time.perf_counter()
    tasks = [asyncio.create_task(wait_for_reply(i)) for i in range(1000)]
    await asyncio.sleep(0)  # every task starts and pauses at its sleep
    waiting, _ = tracemalloc.get_traced_memory()
    replies = [await task for task in tasks]
    took = time.perf_counter() - start
    tracemalloc.stop()
    print(f"replies: {len(replies)}, threads in this program: {threading.active_count()}")
    print(f"memory held by each waiting task: about {(waiting - before) / 1000 / 1024:.1f} KiB")
    print(f"one after another this would take {1000 * 0.2:.0f} s; together it took about {took:.1f} s")


asyncio.run(main())

Output

replies: 1000, threads in this program: 1
memory held by each waiting task: about 1.3 KiB
one after another this would take 200 s; together it took about 0.2 s

This output changes from run to run: the time and the memory are measured on each run; setting up a thousand tasks takes longer on a slower computer and in the browser, whose 32-bit Python also uses smaller objects

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 thousand_waits.py

A thousand replies of 0.2 seconds each, awaited one after another, would take 200 seconds. As tasks they waited at the same time and the whole job took about 0.2 seconds on CPython, in a program with a single thread. tracemalloc, which records the memory Python allocates, put the cost of one waiting task at about 1.3 KiB: the task object, its paused coroutine and the timer that will wake it. A thousand of them fit in about 1.3 MiB, which is why one asyncio process can hold tens of thousands of open connections. The work done between the waits still runs one task at a time, so the total CPU time does not shrink: only the waiting overlaps.

Checkpoint

Check: Waits overlap; computing does not

  1. One question 10,000 tasks each await asyncio.sleep(0.5) at the same time, in one event loop. About how many seconds does the whole job take?

    Type a number.

    A hint

    What does a task need while it waits?

    Show the answer: One question

    Answer: 0.5 seconds (anything from 0.2 to 0.8 counts)

    A waiting task needs no CPU time and no thread, so the 10,000 waits overlap and the job takes about one wait, 0.5 seconds, plus the short time it takes to create the tasks. One after another, the same waits would take 5,000 seconds.

One blocking call stalls every task

Cooperative scheduling has a price. The loop can switch tasks only when the running task awaits, so a task that runs for a long time without awaiting keeps every other task waiting. Here a ticker task asks to run every 0.1 seconds, while main() waits for 0.45 seconds in two ways:

Ticks while main() waits, awaited and busy Python · stalled_loop.py
"""A ticker task should print every 0.1 s. What does it manage while main() waits for 0.45 s?"""
import asyncio
import time


async def ticker(ticks):
    while True:
        await asyncio.sleep(0.1)
        ticks.append(time.perf_counter())


def compute_for(seconds):
    # CPU-bound work: a loop that never awaits, like parsing a large file or resizing an image.
    deadline = time.perf_counter() + seconds
    count = 0
    while time.perf_counter() < deadline:
        count += 1
    return count


async def awaited_wait():
    await asyncio.sleep(0.45)


async def busy_wait():
    compute_for(0.45)


async def measure(label, wait):
    ticks = []
    tick_task = asyncio.create_task(ticker(ticks))
    await asyncio.sleep(0)  # let the ticker start
    await wait()
    tick_task.cancel()
    print(f"{label:30} ticks while it ran: {len(ticks)}")


async def main():
    await measure("await asyncio.sleep(0.45)", awaited_wait)
    await measure("a 0.45 s loop with no await", busy_wait)


asyncio.run(main())

Output

await asyncio.sleep(0.45)      ticks while it ran: 4
a 0.45 s loop with no await    ticks while it ran: 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stalled_loop.py

During await asyncio.sleep(0.45) the ticker ran four times, as it should. During a loop of 0.45 seconds with no await in it, standing in for parsing a big file or resizing an image, the ticker did not run once. Step through both measurements on a clock and watch which code holds the thread:

Interactive · message by message The ticker while main() waits, then while it computes Step through stalled_loop.py. The ticker asks to run every 0.1 s; only the event loop can resume it, and only when main() gives the thread back.
The ticker while main() waits, then while it computes, step 9 of 9. Messages so far: Event loop to ticker: first turn; main() to Event loop: await sleep(0.45); Event loop to ticker: wake at 0.1 s; Event loop to ticker: wake at 0.4 s; Event loop to main(): wake at 0.45 s; Event loop to ticker: first turn; main() to ticker: cancel().main()printed 0 ticksEvent loopwaits for the threadtickercancelled, ticks: 0first turnawait sleep(0.45)wake at 0.1 swake at 0.4 swake at 0.45 sfirst turncancel()

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. The ticker starts. measure() creates the ticker task and awaits asyncio.sleep(0), so the ticker gets a turn. It asks to be woken in 0.1 s and pauses.
  2. main() awaits. main() reaches await asyncio.sleep(0.45). It is parked until 0.45 s, and the event loop has the thread to itself.
  3. 0.1 s, tick 1. The ticker's timer is due, so the loop resumes it. It records tick 1 and asks to sleep for another 0.1 s.
  4. 0.2 to 0.4 s, ticks 2 to 4. The same happens at 0.2, 0.3 and 0.4 seconds, each time at once, because nothing else holds the thread.
  5. 0.45 s, 4 ticks. main() is woken at 0.45 s, cancels the ticker and prints 4 ticks. Every timer ran on time.
  6. A new ticker. The second measurement starts a fresh ticker the same way. It too asks to be woken in 0.1 s.
  7. main() computes. main() calls compute_for(0.45), a loop with no await in it. The thread is busy running it, so the event loop cannot run at all.
  8. 0.1 to 0.4 s, no tick. The ticker's timer is due at 0.1 s, then overdue, but only the loop can resume the ticker, and the loop cannot run until main() gives the thread back.
  9. 0.45 s, 0 ticks. compute_for() returns, and main() cancels the ticker before the loop gets a single turn, so it prints 0 ticks. In a web server, the ticker is every other request.

In a web server, the ticker is every other request: they all wait until the busy one gives the loop back. A blocking call: A call that holds its thread until it finishes, such as time.sleep() or a network client not written for asyncio. Inside a coroutine it stops the event loop, and with it every other task, for as long as it runs. does the same damage as a busy loop. time.sleep() stops the whole thread, and the event loop runs in that thread:

time.sleep() inside a coroutine (recorded on CPython) Python · blocking_sleep.py
"""time.sleep() inside a coroutine blocks the whole event loop (recorded on CPython)."""
import asyncio
import time


async def ticker(ticks):
    while True:
        await asyncio.sleep(0.1)
        ticks.append(time.perf_counter())


async def blocking_wait():
    time.sleep(0.45)  # a blocking call: the thread sleeps, and the event loop with it


async def main():
    ticks = []
    tick_task = asyncio.create_task(ticker(ticks))
    await asyncio.sleep(0)
    await blocking_wait()
    tick_task.cancel()
    print(f"time.sleep(0.45) in a coroutine  ticks while it ran: {len(ticks)}")


asyncio.run(main())

Output

time.sleep(0.45) in a coroutine  ticks while it ran: 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blocking_sleep.py

Note

This program has no Run button. Python in the browser implements time.sleep() by pausing through the browser’s own scheduler, which lets other tasks run during the sleep (Pyodide’s changelog describes the change), so the browser would show four ticks where CPython shows none. The recorded run above is what CPython does on a server or a laptop.

The usual blocking calls in real code are time.sleep(), the HTTP clients and database drivers that are not written for asyncio, reading large files, and long computations. Inside a coroutine, use their async versions: await asyncio.sleep() instead of time.sleep(), an async HTTP client instead of a blocking one. Work that cannot be made async goes to a thread or a process, with asyncio.to_thread() or an executor, which a later lesson of this module covers.

Key idea

The loop can switch tasks only at an await, so anything that runs without one, a busy loop or a blocking call, holds up every other task for as long as it runs.

Checkpoint

Check: A blocking call stalls every task

  1. Question 1 of 2 A ticker task asks to run every 0.1 s. main() then calls a function that computes for 0.3 s without any await, and cancels the ticker as soon as it returns. How many ticks ran?

    Type a number.

    A hint

    Who resumes the ticker, and when can it run?

    Show the answer: Question 1 of 2

    Answer: 0 ticks

    Only the event loop can resume the ticker, and the loop runs only when main() gives the thread back. The computation keeps the thread for 0.3 s and main() cancels the ticker right after it, before the loop gets a turn, so no tick runs at all, as the 0.45 s loop of stalled_loop.py showed.

  2. Question 2 of 2 Inside a coroutine, which of these stop every other task on the loop while they run?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • A loop that hashes a 2 GB file without any await
    • A call to an HTTP client that is not written for asyncio
    • time.sleep(2)

    Anything that holds the thread without awaiting stalls the loop: time.sleep(), blocking libraries and long computations. await asyncio.sleep() is the async version: it parks one task and frees the thread.

A coroutine called without await never runs

The most common asyncio mistake costs no error at all. Calling a coroutine function without await creates a coroutine object and throws it away, so its body never runs:

save() without await never saves Python · forgot_await.py
"""Calling a coroutine function without await: the call does nothing, and Python warns."""
import asyncio
import gc
import warnings

saved = []


async def save(order):
    await asyncio.sleep(0)  # stands in for a database write
    saved.append(order)


async def main():
    save("order-1")  # the mistake: no await, so this only creates a coroutine object
    await save("order-2")


with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    asyncio.run(main())
    gc.collect()  # make sure the forgotten coroutine object has been cleaned up

print("orders saved:", saved)
for warning in caught:
    print(f"{warning.category.__name__}: {warning.message}")

Output

orders saved: ['order-2']
RuntimeWarning: coroutine 'save' was never awaited

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forgot_await.py

Only the second order was saved. The first call made a coroutine that nobody ran, and when Python destroyed it, it issued a RuntimeWarning naming the coroutine. In a normal run the warning goes to standard error, with the file and line of the call; the example collects it with warnings.catch_warnings() only to print it neatly. Treat the message “coroutine … was never awaited” as an error. Asyncio’s debug mode, asyncio.run(main(), debug=True), adds where the coroutine was created, and mypy reports a coroutine whose value is not used, its unused-coroutine check, before the code even runs.

Calling a coroutine function like a plain function

save("order-1") on a line of its own makes a coroutine object and drops it, so the order is never saved. Write await save("order-1") to run it as part of the current task, or pass it to asyncio.create_task() to run it as a task of its own, as the next lesson shows.

Checkpoint

Check: The forgotten await

  1. One question main() runs save("order-1") without await, then await save("order-2"). What happens to order-1?

    Choose one answer.

    Show the answer: One question

    Answer: It is never saved, and Python warns that coroutine 'save' was never awaited

    save("order-1") without await creates a coroutine object and throws it away. Its body never runs, so the order is not saved, and when Python destroys the object it issues RuntimeWarning: coroutine 'save' was never awaited.

asyncio pays off when the time goes to waiting

Two questions decide most cases: where a job’s time goes, and whether async libraries exist for what it calls. The table sets the two sides against each other:

Is asyncio a good fit for this job?
Criterion Good fitPoor fit
Where the time goes waiting for networks, databases and other servicescomputing: parsing, maths, image work
How many things at once hundreds or thousands of connectionsa few heavy jobs
Libraries async clients for HTTP, databases and queues existonly blocking libraries are available
Typical programs crawlers, chat and websocket servers, API fan-out, botsbatch number crunching, video encoding
When to choose asyncio tasks in one threadprocesses, or threads for blocking libraries

Use asyncio when a program spends most of its time waiting and the waits can overlap: a crawler fetching thousands of pages, a chat server holding many idle connections, a service that calls five other services for every request. Do not use it to speed up computation. Two calculations in two tasks still run one after the other on one core, and each blocks the loop while it runs. For CPU-bound work use processes, and for blocking libraries use threads, as the concurrency module of this track measures.

Split one job before you choose

Time a job’s waiting and its computing apart. The waiting overlaps on a loop, so a thousand jobs wait about as long as one; the computing adds up job by job, so a thousand jobs of 10 ms each still need 10 seconds of CPU.

Python Online Compiler Run taking_turns.py with a third worker, or change stalled_loop.py's loop to 0.25 s, and predict the output before you press Run.

Checkpoint

Check: Jobs that asyncio suits

  1. One question A service resizes uploaded photos, about 2 s of CPU work each, and receives 10 uploads at once. What does doing each resize in its own asyncio task change?

    Choose one answer.

    Show the answer: One question

    Answer: Nothing for the speed. The resizes still run one after another, about 20 s, and each blocks the loop while it runs

    asyncio helps only when the waits can overlap. A resize computes the whole time, so ten of them take about ten times 2 s whether or not they are tasks, and every other task waits while each one runs. Processes, or a library that uses several cores, are the right tool.

Practise: make breakfast in the time of the slowest step

The exercise puts the lesson to work in a kitchen. Its starter code runs the steps of a breakfast one after another, so the breakfast takes as long as all of them together; start the steps as tasks so that it takes about as long as its slowest one. The sample tests run in your browser:

Exercise · Easy · Python

Make breakfast in the time of the slowest step

Write make_breakfast(steps, wait) in breakfast.py, a coroutine that runs every step of a breakfast at the same time, so that the whole breakfast takes about as long as its slowest step.

  • steps is a dictionary from a step's name to its duration in minutes, for example {"boil water": 4, "toast bread": 2, "fry eggs": 3}.
  • wait is a coroutine function: await wait(name, minutes) waits for one step. The tests pass in a fake one, so nothing really waits; in a real program it could be asyncio.sleep.
  • Return the names in the order the steps finished: ["toast bread", "fry eggs", "boil water"] for the example. Steps of equal length finish in the order the dictionary lists them.
  • An empty dictionary gives an empty list.

The starter code runs the steps one after another. Its result is right for a single step, but it takes the sum of all the steps instead of the longest one.

Starter code · breakfast.py

async def make_breakfast(steps, wait):
    """Run every step at the same time; return the step names in the order they finished."""
    finished = []
    for name, minutes in steps.items():
        await wait(name, minutes)  # one step at a time: the breakfast takes the sum of the steps
        finished.append(name)
    return finished
The sample tests · test_breakfast.py
import asyncio

from breakfast import make_breakfast


class FakeKitchen:
    """A fake wait(): a step of n minutes lasts n turns of the event loop, so nothing waits for real. It records
    when each step starts and ends, so the tests can see which steps ran at the same time."""

    def __init__(self):
        self.events = []

    async def wait(self, name, minutes):
        self.events.append(("start", name))
        for _ in range(minutes):
            await asyncio.sleep(0)
        self.events.append(("end", name))

    def started_before_any_ended(self):
        kinds = [kind for kind, _ in self.events]
        return kinds.index("end") if "end" in kinds else len(kinds)


async def test_finish_order():
    """the steps finish shortest first"""
    kitchen = FakeKitchen()
    steps = {"boil water": 4, "toast bread": 2, "fry eggs": 3}
    assert await make_breakfast(steps, kitchen.wait) == ["toast bread", "fry eggs", "boil water"]


async def test_all_steps_start_together():
    """every step starts before the first one ends"""
    kitchen = FakeKitchen()
    await make_breakfast({"boil water": 4, "toast bread": 2, "fry eggs": 3, "slice fruit": 1}, kitchen.wait)
    assert kitchen.started_before_any_ended() == 4


async def test_equal_steps_keep_their_order():
    """steps of equal length finish in the order they are listed"""
    kitchen = FakeKitchen()
    steps = {"pour juice": 1, "pour milk": 1, "warm plates": 1}
    assert await make_breakfast(steps, kitchen.wait) == ["pour juice", "pour milk", "warm plates"]


async def test_one_step():
    """a single step works"""
    kitchen = FakeKitchen()
    assert await make_breakfast({"make tea": 3}, kitchen.wait) == ["make tea"]


async def test_no_steps():
    """an empty breakfast is an empty list"""
    assert await make_breakfast({}, FakeKitchen().wait) == []
A hint

Start every step before you wait for any of them: make one task per step with asyncio.create_task(), inside a coroutine that awaits wait(name, minutes) and then appends the name to a list. Await the tasks only after all of them exist.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then test yourself on all seven ideas. Practice starts with the idea your answers show is weakest, moves up a level when you get one right, and says why each choice is right or wrong as soon as you check it:

Practice that adapts to you

Practise this lesson

14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 async def report(): starts with print("building"). The program runs job = report() and, three lines later, asyncio.run(job). When is "building" printed?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: When asyncio.run(job) runs the coroutine

    Calling a coroutine function only creates a coroutine object; none of the body runs. The body starts when an event loop drives the object, here when asyncio.run(job) runs it. Storing the object first changes nothing.

  2. Question 2 of 14 What does result = asyncio.run(greet("Asha")) leave in result, with greet() as in coroutine_object.py?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: The string 'hello, Asha'

    asyncio.run() creates an event loop, runs the coroutine until it returns, closes the loop and returns the coroutine's value. The recorded run printed asyncio.run() returned: 'hello, Asha'.

  3. Question 3 of 14 In hand_driven.py, what does coro.send(None) give back to the driver when checkout() pauses?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: The value the coroutine handed out at the pause, here the reason it waits

    Each send(None) runs the coroutine to its next pause and returns what the pause handed out: "waiting for the payment service", then "waiting for the mail server". An event loop reads such a value to know what the coroutine waits for.

  4. Question 4 of 14 What does hand_driven.py print?

    What does this program print? Choose one answer.

    """An event loop is code that calls send() on coroutines. Here we play the loop by hand."""
    import types
    
    
    @types.coroutine
    def pause(reason):
        # A bare yield is how a coroutine tells whoever drives it: "I am waiting, run something else".
        yield reason
    
    
    async def checkout():
        print("checkout: reserve the items")
        await pause("waiting for the payment service")
        print("checkout: payment done, send the receipt")
        await pause("waiting for the mail server")
        print("checkout: receipt sent")
        return "order 1042 complete"
    
    
    coro = checkout()
    step = 1
    while True:
        try:
            why = coro.send(None)  # run the coroutine until it pauses
        except StopIteration as finished:  # the coroutine returned
            print(f"driver: finished after {step} sends, result {finished.value!r}")
            break
        print(f"driver: send #{step} came back with {why!r}")
        step += 1
    Show the answer: Question 4 of 14

    Answer: it prints

    checkout: reserve the items
    driver: send #1 came back with 'waiting for the payment service'
    checkout: payment done, send the receipt
    driver: send #2 came back with 'waiting for the mail server'
    checkout: receipt sent
    driver: finished after 3 sends, result 'order 1042 complete'

    The first send runs checkout() to its first pause, which hands out the payment reason; the second runs it to the mail-server pause; the third runs it to its return, which raises StopIteration carrying 'order 1042 complete'.

  5. Question 5 of 14 What does asyncio.create_task(worker("upload", 3)) do before it returns?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: It schedules the coroutine as a task; none of the body runs yet

    create_task() wraps the coroutine in a task and puts it in the ready queue. The loop runs it as soon as the code that is running now pauses: in taking_turns.py that is main()'s await upload.

  6. Question 6 of 14 What does taking_turns.py print?

    What does this program print? Choose one answer.

    """Two tasks on one event loop: each runs until its next await, then the loop runs the other."""
    import asyncio
    
    
    async def quick_check(name):
        # Awaiting a coroutine that never waits is an ordinary call: no other task runs during it.
        return f"{name} is valid"
    
    
    async def worker(name, steps):
        for i in range(1, steps + 1):
            print(f"{name}: step {i}, {await quick_check(name)}")
            await asyncio.sleep(0)  # a real pause: the loop may run another task now
        print(f"{name}: done")
    
    
    async def main():
        upload = asyncio.create_task(worker("upload", 3))
        email = asyncio.create_task(worker("email", 2))
        print("main: two tasks created, neither has run yet")
        await upload
        await email
        print("main: both finished")
    
    
    asyncio.run(main())
    Show the answer: Question 6 of 14

    Answer: it prints

    main: two tasks created, neither has run yet
    upload: step 1, upload is valid
    email: step 1, email is valid
    upload: step 2, upload is valid
    email: step 2, email is valid
    upload: step 3, upload is valid
    email: done
    upload: done
    main: both finished

    Creating a task schedules it but does not run it, so main() prints first and only gives the loop control at await upload. From then on each task runs until its next await asyncio.sleep(0), so the two take turns. await quick_check(name) never switches tasks, because quick_check() returns without waiting for anything.

  7. Question 7 of 14 While a task waits on await asyncio.sleep(0.2), what does it hold?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: No thread and no CPU time, only a small object, about 1.3 KiB in the measured run

    An awaited wait parks the task: the loop keeps its paused coroutine and a timer, and the thread is free. That is why a thousand waits of 0.2 s fit in one thread, in about 1.3 MiB, and take about 0.2 s together.

  8. Question 8 of 14 A crawler fetches 2,000 pages in one event loop. Each page is a 1-second wait for the server and then 10 ms of parsing in Python. About how many seconds does the whole job take?

    Type a number.

    A hint

    Split each page into the part that can overlap and the part that cannot.

    Show the answer: Question 8 of 14

    Answer: 21 seconds (anything from 19 to 23 counts)

    The 2,000 waits overlap, so they take about 1 second together. The parsing runs one task at a time, 2,000 × 10 ms = 20 seconds, and does not shrink. The job takes about 21 seconds, against 2,000 × 1.01 s, over half an hour, one page after another: only the waiting overlaps.

  9. Question 9 of 14 A coroutine on a busy event loop must wait one second for a remote service. Which line lets the other tasks run during that second?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: await asyncio.sleep(1)

    await asyncio.sleep(1) pauses the task and gives the loop back for the whole second. time.sleep(1) and a busy loop keep the thread, and with it the event loop, so no other task runs. asyncio.sleep(1) without await only creates a coroutine object that never runs, and Python warns that it was never awaited.

  10. Question 10 of 14 Every request handler of an async server calls time.sleep(0.05), standing in for a database driver that is not written for asyncio. At most how many requests a second can one event loop finish?

    Type a number.

    A hint

    While one handler sleeps, can another one run?

    Show the answer: Question 10 of 14

    Answer: 20 requests a second

    time.sleep() holds the thread, and the loop with it, so the handlers run strictly one after another: 1 ÷ 0.05 = 20 requests a second, however many arrive. With an async driver, or await asyncio.sleep(0.05), the waits would overlap and the limit would be set by the CPU work instead.

  11. Question 11 of 14 What does forgot_await.py print?

    What does this program print? Choose one answer.

    """Calling a coroutine function without await: the call does nothing, and Python warns."""
    import asyncio
    import gc
    import warnings
    
    saved = []
    
    
    async def save(order):
        await asyncio.sleep(0)  # stands in for a database write
        saved.append(order)
    
    
    async def main():
        save("order-1")  # the mistake: no await, so this only creates a coroutine object
        await save("order-2")
    
    
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        asyncio.run(main())
        gc.collect()  # make sure the forgotten coroutine object has been cleaned up
    
    print("orders saved:", saved)
    for warning in caught:
        print(f"{warning.category.__name__}: {warning.message}")
    Show the answer: Question 11 of 14

    Answer: it prints

    orders saved: ['order-2']
    RuntimeWarning: coroutine 'save' was never awaited

    Only the awaited call ran, so saved holds order-2 alone. The forgotten coroutine object was destroyed without running, and Python issued RuntimeWarning: coroutine 'save' was never awaited, which the example collects and prints.

  12. Question 12 of 14 Which sign shows that a coroutine was called without await?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: RuntimeWarning: coroutine '…' was never awaited

    A forgotten await costs no exception. The sign is the RuntimeWarning that names the coroutine; debug mode, asyncio.run(main(), debug=True), adds where it was created, and mypy's unused-coroutine check reports it before the code runs.

  13. Question 13 of 14 When does asyncio make a program faster?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: When most of its time goes to waits that can overlap

    asyncio's gain is overlap of waiting: network replies, database answers, timers. Computation gains nothing, and blocks every other task while it runs.

  14. Question 14 of 14 Which job gains the most from asyncio?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Sending 5,000 webhook calls to partner servers that each take about a second to answer

    The webhook job spends nearly all its time waiting for replies, and those waits can overlap in one thread. The other three jobs compute all the time: asyncio would run their pieces one after another, and each piece would block the loop. They need processes, or C code that uses several cores.

Interview questions

Fresher to 2–5 years · What is the difference between calling a coroutine function and awaiting it?

Calling a coroutine function, such as fetch(url), runs none of its body: it only creates a coroutine object, an object that knows how to run the body later. The body runs when something drives that object: await fetch(url) inside another coroutine runs it as part of the current task, pausing wherever it waits; asyncio.create_task() wraps it in a task that the event loop starts on its own; and asyncio.run() starts a loop to run it from ordinary code. A coroutine object that is never driven does nothing, and when it is garbage-collected Python issues a RuntimeWarning saying that it was never awaited. So the call prepares the work, and the await, a task or asyncio.run() performs it.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An async web service handles about 400 requests a second on one event loop, with a p99 latency of 40 ms. After a release, one endpoint renders a PDF report in pure Python for 300 ms per request, and the p99 of every endpoint rises above one second. Why, and what are your options?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A nightly job downloads 20,000 small files from an object store, about 150 ms each, and parses each one, about 5 ms of CPU time. Would you use asyncio, threads or processes, and how long should the job take?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • async def defines a coroutine function: calling it creates a coroutine object and runs none of its body, and only a driver, asyncio.run(), an await or a task, runs it, from one pause to the next.
  • The event loop runs one task at a time and switches only when the running task awaits something that is not ready; await asyncio.sleep(0) gives up the turn on purpose, and awaiting a coroutine that never waits is an ordinary call.
  • Waits overlap: a thousand waits of 0.2 seconds finished in about 0.2 seconds in one thread, at about 1.3 KiB per waiting task, while the computing between them still runs one task at a time.
  • Anything that runs without awaiting, a busy loop or a blocking call such as time.sleep(), stalls every task on the loop.
  • A coroutine called without await never runs; the “was never awaited” warning is the sign.
  • asyncio suits many network waits; for computation use processes, and for blocking libraries threads.

Terms in this lesson

blocking call
A call that holds its thread until it finishes, such as time.sleep() or a network client not written for asyncio. Inside a coroutine it stops the event loop, and with it every other task, for as long as it runs.
cooperative scheduling
Taking turns where each task decides when to give up control. An asyncio task runs until it awaits something that is not ready, and nothing switches it out before that, so a task that never awaits holds up all the others.
coroutine
The object a call to an async def function returns. Driven by await, a task or asyncio.run(), it runs its body up to each await on something not ready, pauses there with its local variables kept and resumes later from the same spot.
coroutine function
A function defined with async def. Calling it runs none of its body: it returns a coroutine object, which runs only when something drives it, such as await, a task or asyncio.run().
event loop
The part of asyncio that runs tasks one at a time in one thread. It runs a task until it awaits something that is not ready, runs other ready tasks meanwhile, and resumes the first once its timer fires or its data arrives.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.