Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·40 MIN

async/await: the call runs nothing — and three bugs that never crash follow from that

Calling an async function does not run its body: it builds an object and returns. From that follow the forgotten await that turns any check into a truth, the blocking call that stops not one task but the whole event loop and every task on it, and the gather whose second exception stays with its own task and never reaches the caller.

Full technical treatment

TL;DR

A declaration, a call, an await and a task are four different things. async def creates a coroutine function; calling it does not run the body, it builds an object and returns; await starts that object and waits for its result — and that is also where the work yields control to others; a task is a coroutine handed to the event loop, and it runs on its own.

Hence the main consequence: three bugs that never crash. A forgotten await turns if is_allowed(user) into if <object>, which is true for any data — verified: chuck, who is not on the list, passes the check. One time.sleep inside a coroutine stops not its own work but the whole event loop: five 0.1 s tasks took 0.50 s instead of 0.1 s, and a neighbouring task obliged to wake every 10 ms woke 2 times out of 51. And gather propagates to the caller the first exception raised and cancels no siblings — they keep running, and the errors they raise afterwards are not collected by that call: the second error stays with its own task and reaches whoever kept a reference and asked task.exception().

Beyond that: numbers, versions and boundaries. The async function's own type is the ordinary function, differing only by the CO_COROUTINE flag on its code. The RuntimeWarning about "never awaited" is printed at garbage collection, not at the moment of the mistake, and even -W error::RuntimeWarning does not stop execution, is not caught by except and does not change the exit code — verified, the exit code is 0. TaskGroup (3.11) works differently: it cancels the siblings and raises both exceptions as one ExceptionGroup. The price of the machinery itself (3.13.7): a plain call is 60.0 ns, an await of a coroutine 119.4 ns — twice as much; await asyncio.sleep(0) is 2.42 µs — forty times.

Where to start
Before this lesson it is enough to understand
  • what a function is, and that calling one normally runs its body;
  • that a program often waits for something outside it — the network, a disk, another service — and computes nothing while it waits;
  • that an exception can be caught, and that a try can have a finally.
You do not need to know in advance
  • how the event loop is built inside, what CO_COROUTINE and GET_AWAITABLE are;
  • gather, TaskGroup, ExceptionGroup and except*, asyncio.timeout, shield, CancelledError.

Base: four things and a queue of work

Talk about async/await usually goes wrong in one place: four different things get called "a coroutine". So let us separate them straight away, in ordinary words.

  1. The declaration. async def work(...) creates a function of a special kind — a coroutine function. That is not work yet, it is a description of work.
  2. The call. work(1) runs nothing. It builds an object: a suspended computation, ready to start but not started.
  3. The await. await work(1) starts that object and waits for its result. And this is where the second, less visible thing happens: for the duration of the wait the work yields control.
  4. The task. asyncio.create_task(work(1)) hands the object to the event loop. From then on the work proceeds by itself, alongside the current one, and its result is taken from the task object.

Who it yields control to is the other half of the model. The event loop is not a thread and not a process: it is a dispatcher holding a list of work that has been started. It follows one rule, and the rule fits in a sentence: one piece of work reached an await, gave control back, and the dispatcher handed it to the next. While the first waits for an answer from outside, the second computes; when the answer arrives, the first is resumed where it stopped.

Which shows what all of it rests on: work must give control back itself. Nothing takes it away by force, and until the current piece of work gives it up, everything else stands still — timers and the liveness probe included.

And here is the question the rest of the lesson answers: what happens when control is not given back — or when the await is simply forgotten? Neither looks like an error: the program does not crash and writes nothing to the log.

That is already enough to answer the basic interview question. Everything below is about a call without an await handing you a truthy object instead of an answer, about one synchronous call stopping not its own work but the whole list, and about several pieces of work started at once not delivering all their errors to the caller.

Mechanism 1: the call does not run the body

language contractLanguage guarantee: calling a coroutine function builds an object and does not run the body. Written in PEP 492 and unchanged since.

Start with the two lines that verify it — everything else follows from them.

PYTHON
async def work(x):
    return x + 1
 
result = work(1)

result is not 2. It is an object of type coroutine, and the body never ran. PEP 492 says so directly, by comparison with generators: "Regular generators, when called, return a generator object; similarly, coroutines return a coroutine object."

The function itself is an ordinary function:

what we look atasync def workdef plain
type(...)functionfunction
type(work) is type(plain)True
bool(co_flags & CO_COROUTINE)TrueFalse
what the call returnscoroutineint

There is exactly one difference and it lives on the code object, not on the function type. Seeing async def, the compiler sets CO_COROUTINE, and calling such a function takes a different path: build, don't run.

The word await in bytecode is GET_AWAITABLE plus a send loop over SEND. On 3.12 and newer END_SEND joins them; on 3.11 it is absent. The loop itself does not need unpacking — one thing matters: await is an instruction, not a call, and without it the coroutine object stays an object.

A coroutine can be awaited once. A second await of the same object gives RuntimeError: cannot reuse already awaited coroutine on all four versions. So a coroutine cannot be stored in a variable and reused: what gets reused is the function that creates it.

Coroutine states, and the second thing they get confused with

A coroutine object has the same four states a generator has — up to the names, and that is no coincidence: the machinery is one and the same. A run of bench/iterators/states.py walks them and separates the coroutine from the task along the way:

The difference between the two is not convenience but ownership:

coroutine objecttask (Task)
what it isa suspended computationa wrapper the event loop drives
who runs itnobody until it is handed to the loopthe event loop, by itself
can it be awaited twiceno: RuntimeErroryes: the result lives in the task
can it be cancelledthere is nothing to canceltask.cancel()
does it know the result afterwardsnoyes, task.result() still answers

Hence the practical consequence usually learnt through debugging: await coro and await asyncio.create_task(coro) are not the same thing. The first runs the coroutine sequentially inside the current task; the second hands it to the loop as a separate task, which starts moving alongside the current one before the await is even reached. The whole gather section below is, in fact, about what happens to such separate tasks when one of them fails.

Mechanism 2: the bug that never crashes

A permission check. Nine lines of the sort every service has a dozen of:

PYTHON
ALLOWED = {"anna", "boris"}
 
async def is_allowed(user: str) -> bool:
    ...                       # a trip to the database
    return user in ALLOWED
 
async def handle(user, action):
    if is_allowed(user):      # <- await forgotten
        await action()

The function returns a bool and the result goes into an if — it all looks right. But is_allowed(user) returns a coroutine object rather than a bool, and any object is true in an if. The check no longer depends on the data:

userawait is_allowed(user)is_allowed(user)
anna (on the list)TrueTrue
chuck (not on the list)FalseTrue

No exception, no crash, and no test failure if the tests only exercise the permitted user. chuck gets through.

Why the warning does not save you

RuntimeWarning: coroutine ... was never awaited cannot be relied on, for two reasons.

First: it is not printed when the mistake is made. PEP 492 ties it to garbage collection: "When a native coroutine is garbage collected, a RuntimeWarning is raised if it was never awaited on." The measurement agrees: while references to the coroutines are alive there are zero warnings, however often gc.collect() is called. They appear only when the object is destroyed — and in a live service that may happen later, in another request, on another log line.

Second, and worse. The warning is raised inside the object's finaliser, and exceptions from there are swallowed by the interpreter. So even the strictest setting available does not turn it into an error:

python -W error::RuntimeWarning never_awaited.py
what is expectedwhat happens
except RuntimeWarning catches itno
execution stopsno, it runs to the end
the exit code becomes non-zerono, 0

Meanwhile stderr shows Exception ignored in: <coroutine object ...> (3.14 words it differently: Exception ignored while finalizing coroutine), and that is all. A CI configured to "fail on any warning" will not catch this particular bug — it will catch everything except it.

What does catch it — and precision matters here, because "add mypy" does not help everywhere. In an assignment it catches by default:

ok: bool = is_allowed(user)
error: Incompatible types in assignment (expression has type
       "Coroutine[Any, Any, bool]", variable has type "bool")
note: Maybe you forgot to use "await"?

But in if is_allowed(user) — the very case this section opened with — mypy says nothing, even under --strict. It needs a separate error code:

mypy --enable-error-code truthy-bool

Then it reports: "returns "Coroutine[Any, Any, bool]" which does not implement __bool__ or __len__ so it could always be true in boolean context".

ruff does not catch a forgotten await at all, but it catches the other two bugs in this lesson: RUF006 for a task whose reference was not kept, and ASYNC251 for a blocking call inside async def.

Mechanism 3: one blocking call stops everything

The second consequence of the same fact. await is where a coroutine yields control to the event loop. An ordinary call is not such a place, and the loop never learns about it.

Five 0.1 s "requests", with a heartbeat task alongside that is obliged to wake every 10 ms:

What to look at is not the first switch but the two in the middle: press time.sleep, then “sequential await”, and compare the three bars.

gather with time.sleep inside took 0.50 s. A sequential await with no gather at all took 0.51 s — the same. By total time the two are indistinguishable, and the complaint "the service is slow" does not tell them apart either.

Yet what they do to the application is opposite. Under sequential await the heartbeat fired 51 times with a worst gap of 10 ms: slow, but alive. Under the blocking call — 2 beats out of 51 and a half-second gap. What froze is not five tasks but everything: other requests, timers, the liveness probe, metric delivery. At that moment the load balancer is looking at a dead instance.

The remedy is to hand the blocking call to a thread:

PYTHON
await asyncio.to_thread(legacy_client.fetch, url)

That restores 0.1 s and a healthy heartbeat. The price is a thread per call, which makes to_thread a remedy for someone else's synchronous code rather than a way to write your own.

Mechanism 4: where gather and TaskGroup part ways

Three tasks, two of which fail. The difference between the two ways of starting them shows up at precisely that moment — and it is not about how the code reads.

With gather the caller received the second task's ValueError. The third task's KeyError did not reach the caller, and the first task kept running — after error handling had already begun, quite possibly on top of a rollback in progress.

But "vanished" is the wrong word, and the difference is a practical one. The second error stays with its own task; it disappears only when nobody kept a reference to that task. A run of bench/async-await/gather_semantics.py shows both halves on one set of tasks:

the caller got: ValueError: error from fails-first
right after gather: slow done=False, late done=False
slow: cancelled=False, result='slow is done'
late: cancelled=False, exception=RuntimeError('error from fails-late')

The siblings were not cancelled, the slow one ran to the end and returned its result, and the late failure lies there waiting to be asked. The second block of the same run is the same gather with nobody holding references; so that "lost" stops being a word taken on trust, the event loop's exception handler is replaced and its calls are counted:

messages from the event loop about unretrieved errors: 0

Zero — which is stronger than "we did not see it": normally the loop reports an unretrieved task exception itself ("Task exception was never retrieved"). Here it does not, because gather retrieved it — and dropped it. The output is identical on 3.11, 3.12, 3.13 and 3.14: the run records match line for line.

The documentation describes the first half plainly: "Other awaitables in the aws sequence won't be cancelled and will continue to run." It says nothing about the second exception that the call does not collect, but that is exactly what motivated PEP 654: "There isn't currently a good way for such libraries to handle situations where multiple tasks raise exceptions."

TaskGroup (3.11) raises both exceptions as one ExceptionGroup and cancels the siblings: "The first time any of the tasks belonging to the group fails with an exception other than asyncio.CancelledError, the remaining tasks in the group are cancelled." The group is unpacked with except*.

PYTHON
try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch(a))   # say this one fails with ValueError
        tg.create_task(fetch(b))   # and this one with KeyError
except* ValueError as eg:
    # eg is not a single exception but an ExceptionGroup of ALL the ValueErrors
    for err in eg.exceptions:
        log("value:", err)
except* KeyError as eg:
    # a separate clause for KeyError; if tasks of both types failed,
    # BOTH clauses run for one exit from the TaskGroup
    for err in eg.exceptions:
        log("key:", err)
except* Exception as eg:
    # a catch-all so no type leaves unhandled; without this clause,
    # unmatched errors keep propagating as their own ExceptionGroup
    for err in eg.exceptions:
        log("other:", err)

Each except* clause receives a subgroup — an ExceptionGroup with only its own errors; several clauses may run for one exit from the TaskGroup, and types caught by none keep propagating as a group. A full walk-through of except* is in the Exceptions and finally lesson.

The recommendation is written out in What's New for 3.11: "For new code this is recommended over using create_task() and gather() directly." There is one substantial caveat: gather returns results in argument order, while TaskGroup returns nothing — results are taken from the task objects. The swap is not mechanical.

Mechanism 5: a task nobody references

Another place where the documentation warns and the warning cannot be checked.

asyncio.create_task carries this: "The event loop only keeps weak references to tasks. A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done." The advice is to keep a reference.

The measurement tries to catch the loss head on: two hundred tasks whose references are dropped immediately, with gc.collect() between steps. It did not reproduce: all two hundred finished, none was collected. The reason is visible in the same run — while a task is waiting it is referenced by a TaskStepMethWrapper held by the loop's timer, and that reference is strong.

The negative result is the content here. The documented advice is correct, but it is not something a test verifies: since the loss cannot be produced on demand, it will show up once and not where anyone is looking. Keep the reference not because the task will disappear today, but because there would be nothing to notice its disappearance with.

PYTHON
background: set[asyncio.Task] = set()
 
task = asyncio.create_task(worker())
background.add(task)
task.add_done_callback(background.discard)

On 3.11 and later TaskGroup usually removes the need for this: it keeps the references itself and waits for everyone on exit.

Mechanism 6: cancellation is control flow, not an error

A task that gets cancelled receives a CancelledError inside it. What follows is the reason cancellation deserves a section of its own: this exception does not behave like an error.

First, where it sits in the hierarchy:

__mro__                                        ('CancelledError', 'BaseException', 'object')
issubclass(CancelledError, Exception)          False

CancelledError inherits directly from BaseException, and since 3.8 the documentation records that with its own change note:

Changed in version 3.8: CancelledError is now a subclass of BaseException rather than Exception.

asyncio — Exceptions

The reason is the one that keeps KeyboardInterrupt and SystemExit outside Exception, and it is taken apart in the lesson on exceptions: so that cancellation is not caught by accident by code that catches errors. Two traps follow, and both are visible by running (bench/cancellation/01_cancellederror_is_baseexception.py):

except Exception                               let it through
except BaseException                           CAUGHT

The first trap: except Exception does not see cancellation. Cleanup code written as "catch everything and tidy up" will not run on cancellation — control never reaches it.

The second is the opposite, and worse. Catch the cancellation and fail to re-raise it, and the task counts as having finished successfully:

b) try/except BaseException/finally, WITHOUT raise
    task.cancelled()                           False
    result                                     await task returned: 'returned a value as if nothing happened'

Whoever cancelled it receives a result instead of a cancellation. The documentation is direct about this:

In case asyncio.CancelledError is explicitly caught, it should generally be propagated when clean-up is complete.

asyncio — Task Cancellation

And, in the same place, why this is not a matter of taste:

The asyncio components that enable structured concurrency, like asyncio.TaskGroup and asyncio.timeout, are implemented using cancellation internally and might misbehave if a coroutine swallows asyncio.CancelledError.

Ibid.

finally has no budget

The common belief that "cleanup gets a moment" is wrong. An await inside finally works as it always does, and the loop waits exactly as long as the cleanup asked for (bench/cancellation/02_finally_budget.py):

cleanup asked forfrom cancel() to the end of the task
0.05 s0.05 s
0.30 s0.30 s

There is no limit. One appears only when the cancelling side cancels again: a second cancel() cuts the cleanup off right at its await.

There is a practical consequence for shutting a program down. On its way out, asyncio.run cancels whatever is left and waits — with no time limit at all (Lib/asyncio/runners.py:198). A task with slow cleanup delays the process by exactly the length of that cleanup.

Next to this sits something usually taken for a flag, which is in fact a counter:

start                                          0
cancel()                                       1
cancel() again                                 2
uncancel() returned                            1

Task.cancelling() counts cancellation requests and Task.uncancel() subtracts them. They exist not for application code but for asyncio.timeout and TaskGroup: that counter is how they tell "I cancelled this myself" from "we were cancelled from outside" (Lib/asyncio/timeouts.py:112).

A timeout is a cancellation inside and an error outside

asyncio.timeout does not "interrupt" the block. It cancels the task, and on the way out substitutes a TimeoutError for the cancellation:

| a CancelledError arrived from inside the block
| outside the block: TimeoutError
| chain: TimeoutError <-cause- CancelledError

The asyncio.timeout context manager is what transforms the asyncio.CancelledError into a TimeoutError, which means the TimeoutError can only be caught outside of the context manager.

asyncio — Timeouts

In practice this means one and the same event is caught by different branches inside and outside the block: inside, only by except BaseException; outside, by an ordinary except Exception, because TimeoutError inherits from OSError.

And if the cancellation is swallowed inside the block, the timeout silently does not happen (bench/cancellation/03_timeout_vs_wait_for.py):

| swallowed the cancellation from the timeout
| the body ran to completion even though the deadline had passed
| no TimeoutError arrived
| a block with a 0.05 s limit took 0.15 s

Not "fired late" and not "broke" — it simply ceased to exist. Turning the cancellation into a TimeoutError is possible only if __aexit__ saw an exception; swallow it, and there is nothing to see.

shield protects the work, not the waiter

The commonest misunderstanding about shield is thinking it cancels the cancellation. It cancels it only for the inner operation:

outer.cancelled()                              True
inner.cancelled()                              False
inner.result()                                 'inner-result'

The waiter got a CancelledError and stopped waiting; the shielded task ran to completion. The documentation puts it plainly:

From the point of view of something(), the cancellation did not happen. Although its caller is still cancelled, so the "await" expression still raises a CancelledError.

asyncio — shield

Hence the trap that in production looks like "the timeout fired but the request went out anyway": under asyncio.timeout the shielded operation kept working for 0.25 s after the TimeoutError had already arrived outside.

And a second one: shield over a bare coroutine creates a new task, and the loop holds only weak references to tasks. You have to keep the reference yourself — otherwise you get the case taken apart above, in the section about a task nobody references.

What cancellation does inside a TaskGroup

Three things here, each surprising on its own (bench/cancellation/05_taskgroup_cancellation.py).

A CancelledError from a child does not bring the group down. A child cancelled on its own does not land in the error list, does not cancel its siblings, and the group exits normally. This is not an accident but a line in the implementation: if task.cancelled(): return (Lib/asyncio/taskgroups.py:235).

External cancellation of the parent gives a CancelledError outside, not an ExceptionGroup. The children are cancelled, their finally blocks run, but no exception-group wrapper appears — being cancelled is not the same as failing.

The order in which siblings are cancelled is not reproducible. The group keeps its tasks in a set (taskgroups.py:35), so different runs cancel them in different orders. That cancellation happens is reproducible; the order is not, and nothing may depend on it.

Deeper: what await costs

measured observationbench/async-await/cost.py, CPython 3.13.7. Absolute nanoseconds depend on the machine; the ratios between rows are what matter.

Three different prices that all get called "async". The figures were taken on 3.13.7 and 3.14.7, and they must be read along rows, not down columns — the reason follows the table.

what3.13.73.14.7times the cost of a call (3.13.7)
plain function call60.0 ns49.5 ns
await of a coroutine119.4 ns112.9 ns×2.0
await asyncio.sleep(0)2.42 µs2.33 µs×40.4
await create_task(...)6.09 µs5.30 µs×101.6

Why along rows and not down columns. The multipliers in the last column were measured inside a single run of a single interpreter and hold. The columns, however, are two different builds, and they differ by more than the language version: 3.14 is built with --with-tail-call-interp, which 3.13 does not and cannot have, and What's New in 3.14 credits that flag with "a geometric mean of 3-5% faster". These measurements cannot separate "the language got faster" from "the build got faster", so no such conclusion is drawn here. A separate script prints what each build contains.

The first row has a practical consequence: splitting async code into small coroutines is not free, an await costs twice a call. The second and third carry the main point: the gain from async does not come from call speed. A trip through the event loop costs forty times a call, and a task a hundred. That is repaid only when the processor is busy with something else during a real wait. On a compute-bound job with no waiting, asyncio speeds up nothing, and every await in it costs twice a plain call.

What changed in 3.14

implementation detail · CPython 3.13Which object holds the reference to a running task is how the current implementation works, not a promise asyncio makes.

create_task and its sibling methods accept arbitrary keyword arguments and pass them to the task constructor or factory: "now take an arbitrary list of keyword arguments." The name and context arguments stopped being special — "The name and context keyword arguments are no longer special." For application code this changes nothing; for a custom task factory it changes the contract.

Two introspection functions appeared — capture_call_graph() and print_call_graph(): "two new utility functions for introspecting and printing a program's call graph." This is the first supported way to see what a hung task is actually waiting on.

About the figures in the table above, plainly. create_task measures faster on 3.14.7 than on 3.13.7: 5.30 against 6.09 µs, and that fifteen-percent gap is larger than the instrument's own wobble — the run repeats the whole measurement three times and prints it: 6.6% on 3.13.7 and 0.5% on 3.14.7. But it cannot be attributed to asyncio work, for two independent reasons. First, the 3.14 change list contains no asyncio optimisations at all, and the plain function call got faster in the same measurement — 49.5 against 60.0 ns — which has nothing to do with asyncio. Second, the two builds differ by more than the version: 3.14 has --with-tail-call-interp enabled, of which the same document says "a geometric mean of 3-5% faster" and "This feature is opt-in for now".

So on the question "did asyncio get faster in 3.14", these measurements give no answer — and the right conclusion from them is not "it did" but "this cannot be measured here".

Version history

VersionChangeWhat this means for your code
3.5PEP 492 makes coroutines a language feature rather than a layer over generators: “This proposal makes coroutines a native Python language feature, and clearly separates them from generators”. The RuntimeWarning for a forgotten await arrives at the same time — already tied to garbage collection, so with both caveats this lesson demonstrates.
3.11TaskGroup and asyncio.timeout arrive, and the documentation prefers both immediately: “For new code this is recommended over using create_task() and gather() directly” and “recommended over using wait_for() directly”. PEP 654 lands in the same release with ExceptionGroup and except*, without which TaskGroup would have had no way to raise two errors at once.
3.13TaskGroup.create_task() on an inactive group now closes the coroutine it was given, “which prevents a RuntimeWarning about the given coroutine being never awaited”. The same release reworks cancellation in nested groups, where an outer group could previously hang because the inner one swallowed its cancellation.
3.14create_task accepts arbitrary keyword arguments, and name and context stop being special — irrelevant to application code, relevant to a custom task factory. capture_call_graph() and print_call_graph() arrive: a supported way to see what a hung task is waiting on.

How to answer in an interview

The short answer: calling an async function does not run its body. It builds a coroutine object and returns; execution only begins where that object is awaited, and that is also where the work yields control to others. Hence the forgotten await: if is_allowed(user) turns into a truthiness test on an object, and so passes for any data.

That is enough to answer correctly. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

The function's own type is the ordinary function, differing only by the CO_COROUTINE flag on its code — an "async function" is not a separate kind of object but an ordinary function with a flag.

What separates a good answer: saying that the warning about it arrives too late and too quietly. The "never awaited" RuntimeWarning is printed during garbage collection rather than at the point of the mistake, and even with -W error::RuntimeWarning it neither stops execution nor changes the exit code. If asked further — two more things that are easy to get wrong: a single time.sleep inside a coroutine stops the whole event loop, and gather propagates to the caller the first exception raised and cancels no siblings — they keep running, and the errors they raise afterwards are not collected by that call: the second error stays with its own task and reaches only whoever kept a reference and asked; TaskGroup cancels the siblings and raises both.

Next they ask

Next they ask

You said calling a coroutine runs nothing. Then why does one time.sleep inside a task stall the whole loop?

Short answer

Because what yields control is not the call but the await. An ordinary call does not yield, and the loop never learns about it. Hence the indistinguishability that makes the bug quiet: gather with time.sleep inside took 0.50 s, and a sequential await with no gather at all took 0.51 s. By total time the two are the same, and "the service is slow" does not tell them apart either.

Next they ask

A task is cancelled. How long does finally get for cleanup?

Short answer

As long as the cleanup asks for. The common "cleanup gets a moment" is wrong: await inside finally works as usual and the loop waits for exactly what was requested — 0.05 s for 0.05, 0.30 s for 0.30. A limit appears only when the cancelling side calls cancel() again: the second cancellation cuts the cleanup off at its await.

Next they ask

gather or TaskGroup — which would you take, and why?

Short answer

The difference is not notation but what happens when two of three tasks fail. With gather the caller receives the first exception raised; the remaining tasks keep running, and the errors they raise afterwards are not collected by that call — the second error stays with its own task and reaches only whoever kept a reference and called task.exception(). With no references kept it reaches nobody — not the caller, not the log, not even the event loop's own warning. And the siblings run on, quite possibly on top of a rollback already under way. The documentation states the first half plainly; the second is what motivated PEP 654.

Common misconceptions

Claim

async makes code faster

Actually

On its own it makes code slower. An await of a coroutine costs twice a plain call (119.4 against 60.0 ns on 3.13.7), a trip through the event loop forty times (2.42 µs), creating a task a hundred (6.09 µs). The gain comes only from keeping the processor busy during a REAL wait. On compute with no waiting, asyncio speeds up nothing.

Claim

a forgotten await is visible: there will be a RuntimeWarning

Actually

The warning is printed at garbage collection, not at the moment of the mistake: while a reference to the coroutine is alive there is none at all. And even -W error::RuntimeWarning does not stop it — the warning is raised in a finaliser, where exceptions are swallowed. Verified: except RuntimeWarning does not fire, execution runs to the end, the exit code is 0.

Claim

code inside async def does not block

Actually

It blocks exactly the same. await is where a coroutine yields control; an ordinary call is not such a place. Measured: five tasks with time.sleep(0.1) inside gather took 0.50 s instead of 0.1 s, and a neighbouring 10 ms task woke 2 times out of 51. What stops is not the task but the whole loop.

Claim

slow async and blocked async are the same thing

Actually

Indistinguishable by total time, opposite in consequence. Sequential await — 0.51 s and 51 heartbeats; gather with a blocking call — 0.50 s and 2 heartbeats. In the first case the application is alive and merely slow; in the second it ceases to exist for half a second as far as the load balancer and metrics are concerned.

Claim

gather waits for everyone and shows every error

Actually

Neither. The documentation: “the first raised exception is immediately propagated… Other awaitables in the aws sequence won't be cancelled and will continue to run”. A run of bench/async-await/gather_semantics.py: the caller saw one error; the second stayed with its own task and reads back through task.exception() if the reference was kept; the slow task calmly ran to completion after error handling had begun. With no references kept, nobody showed the second error at all — the event loop's exception handler never fired once.

Claim

TaskGroup is just a prettier gather

Actually

The difference is behaviour on error. TaskGroup cancels the remaining tasks and raises every exception as one ExceptionGroup; gather cancels nobody and raises the first. Nor is the swap mechanical: gather returns results in argument order, while TaskGroup returns nothing — results are taken from the task objects.

Claim

a coroutine can be stored and awaited twice

Actually

A second await of the same object gives RuntimeError: cannot reuse already awaited coroutine on all four versions. What is reused is the function that creates the coroutine, not the coroutine. That is why a retry helper takes a function rather than a ready object.

Claim

create_task is enough, keeping a reference is optional

Actually

The documentation warns otherwise: “A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done”. A head-on attempt to reproduce the loss failed — two hundred unreferenced tasks all finished, because a sleeping task is held by the loop's timer. That is an argument FOR caution, not against it: a bug that cannot be produced on demand will not be caught by a test either.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

A permission check written as an async function. The await is missing in the condition and present one line below. What does this code print?
import asyncio

ALLOWED = {"alice", "bob"}


async def is_allowed(user):
  return user in ALLOWED


async def main():
  if is_allowed("chuck"):
      print("allowed")
  else:
      print("denied")
  print(await is_allowed("chuck"))


asyncio.run(main())

Practice · estimate

An ordinary call inside a coroutine against await asyncio.sleep(0) — that is, one turn of the event loop. How many times more expensive is the turn?
times

Knowledge check

Question 1 of 5

What ends up in the variable after result = work(1), where work is declared async def?

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Sources & further reading

8 SOURCES

  1. PEP 492 — Coroutines with async and await syntaxPEP. Yury Selivanov, Final, Python 3.5. Two things this lesson stands on. First: “Regular generators, when called, return a generator object; similarly, coroutines return a coroutine object” — the call builds an object, it does not run the body. Second: “When a native coroutine is garbage collected, a RuntimeWarning is raised if it was never awaited on” — the warning is tied to garbage collection, and the measurement agrees: while a reference is alive there is no warning at all.https://peps.python.org/pep-0492/
  2. asyncio — coroutines and tasksOfficial documentation. The source of three statements verified by running code. On gather: “If return_exceptions is False (default), the first raised exception is immediately propagated to the task that awaits on gather(). Other awaitables in the aws sequence won't be cancelled and will continue to run”. On TaskGroup: “The first time any of the tasks belonging to the group fails with an exception other than asyncio.CancelledError, the remaining tasks in the group are cancelled”. And the direct recommendation: “TaskGroup provides stronger safety guarantees than gather for scheduling a nesting of subtasks”.https://docs.python.org/3.14/library/asyncio-task.html
  3. PEP 654 — Exception Groups and except*PEP. Irit Katriel, Yury Selivanov, Guido van Rossum; Final, Python 3.11. The Motivation names exactly the problem the measurement shows: “Libraries for async concurrency provide APIs to invoke multiple tasks and return their results in aggregate. There isn't currently a good way for such libraries to handle situations where multiple tasks raise exceptions”. Hence the ExceptionGroup that TaskGroup raises.https://peps.python.org/pep-0654/
  4. What's New in Python 3.11 — asyncioOfficial documentation. The year both arrived: “Added the TaskGroup class, an asynchronous context manager holding a group of tasks that will wait for all of them upon exit. For new code this is recommended over using create_task() and gather() directly” and “Added timeout(), an asynchronous context manager for setting a timeout on asynchronous operations. For new code this is recommended over using wait_for() directly”. Both recommendations are written down, not inferred.https://docs.python.org/3.14/whatsnew/3.11.html
  5. What's New in Python 3.13 — asyncioOfficial documentation. The change that bears on a forgotten await: “When TaskGroup.create_task() is called on an inactive TaskGroup, the given coroutine will be closed (which prevents a RuntimeWarning about the given coroutine being never awaited)”. The same section reworks cancellation in nested TaskGroups, where an outer group could previously hang.https://docs.python.org/3.14/whatsnew/3.13.html
  6. What's New in Python 3.14 — asyncioOfficial documentation. Two entries. First: create_task “now take an arbitrary list of keyword arguments”, and “The name and context keyword arguments are no longer special”. Second: “There are two new utility functions for introspecting and printing a program's call graph: capture_call_graph() and print_call_graph()”. There are NO asyncio optimisations in the list — which matters for how the measurement in this lesson should be read.https://docs.python.org/3.14/whatsnew/3.14.html
  7. asyncio — ExceptionsOfficial documentation. Where `CancelledError` sits in the hierarchy, recorded as a change note: “Changed in version 3.8: CancelledError is now a subclass of BaseException rather than Exception”. The same page on what to do with a caught cancellation: “This exception can be caught to perform custom operations when asyncio Tasks are cancelled. In almost all situations the exception must be re-raised”. And on the timeout: “Changed in version 3.11: This class was made an alias of TimeoutError”.https://docs.python.org/3.13/library/asyncio-exceptions.html
  8. The asyncio source — Lib/asyncio/tasks.py, Lib/asyncio/timeouts.py, Lib/asyncio/taskgroups.py, Lib/asyncio/runners.pyCPython source code. The places the cancellation section rests on, at tag v3.13.7. `exceptions.py:10` — `class CancelledError(BaseException)`. `tasks.py:223` — `self._num_cancels_requested += 1`, a counter rather than a flag; `tasks.py:248` — `uncancel`, which since 3.13 also clears `_must_cancel` on reaching zero. `timeouts.py:112` — `if self._task.uncancel() <= self._cancelling and exc_type is not None:`, the comparison by which a timeout tells its own cancellation from an external one, and from the same place `raise TimeoutError from exc_val`. `taskgroups.py:235` — `if task.cancelled(): return`, which is why a cancelled child does not bring the group down; `taskgroups.py:35` — `self._tasks = set()`, which is why the order in which siblings are cancelled is not reproducible. `runners.py:198` — `_cancel_all_tasks`: on the way out of `asyncio.run`, every cancelled task is given as long as it needs.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/tasks.py