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.
- 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
trycan have afinally.
- how the event loop is built inside, what
CO_COROUTINEandGET_AWAITABLEare; gather,TaskGroup,ExceptionGroupandexcept*,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.
- 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. - The call.
work(1)runs nothing. It builds an object: a suspended computation, ready to start but not started. - 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. - 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
Start with the two lines that verify it — everything else follows from them.
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 at | async def work | def plain |
|---|---|---|
type(...) | function | function |
type(work) is type(plain) | True | |
bool(co_flags & CO_COROUTINE) | True | False |
| what the call returns | coroutine | int |
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 object | task (Task) | |
|---|---|---|
| what it is | a suspended computation | a wrapper the event loop drives |
| who runs it | nobody until it is handed to the loop | the event loop, by itself |
| can it be awaited twice | no: RuntimeError | yes: the result lives in the task |
| can it be cancelled | there is nothing to cancel | task.cancel() |
| does it know the result afterwards | no | yes, 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:
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:
| user | await is_allowed(user) | is_allowed(user) |
|---|---|---|
anna (on the list) | True | True |
chuck (not on the list) | False | True |
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 expected | what happens |
|---|---|
except RuntimeWarning catches it | no |
| execution stops | no, it runs to the end |
| the exit code becomes non-zero | no, 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:
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*.
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.
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.
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.
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.
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 for | from cancel() to the end of the task |
|---|---|
| 0.05 s | 0.05 s |
| 0.30 s | 0.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.
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.
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
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.
| what | 3.13.7 | 3.14.7 | times the cost of a call (3.13.7) |
|---|---|---|---|
| plain function call | 60.0 ns | 49.5 ns | — |
await of a coroutine | 119.4 ns | 112.9 ns | ×2.0 |
await asyncio.sleep(0) | 2.42 µs | 2.33 µs | ×40.4 |
await create_task(...) | 6.09 µs | 5.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
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
| Version | Change | What this means for your code |
|---|---|---|
| 3.5 | PEP 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.11 | TaskGroup 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.13 | TaskGroup.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.14 | create_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
You said calling a coroutine runs nothing. Then why does one time.sleep inside a task stall the whole loop?
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.
A task is cancelled. How long does finally get for cleanup?
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.
gather or TaskGroup — which would you take, and why?
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
async makes code faster
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.
a forgotten await is visible: there will be a RuntimeWarning
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.
code inside async def does not block
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.
slow async and blocked async are the same thing
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.
gather waits for everyone and shows every error
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.
TaskGroup is just a prettier gather
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.
a coroutine can be stored and awaited twice
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.
create_task is enough, keeping a reference is optional
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
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
Knowledge check
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.
bench/cancellation/01_cancellederror_is_baseexception.pybench/cancellation/02_finally_budget.pybench/cancellation/03_timeout_vs_wait_for.pybench/cancellation/04_shield.pybench/cancellation/05_taskgroup_cancellation.pybench/async-await/blocking.pybench/async-await/cost.pybench/async-await/fire_and_forget.pybench/async-await/forgotten_await.pybench/async-await/gather_semantics.pybench/async-await/groups.pybench/async-await/identity.pybench/iterators/states.py
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A declaration, a call, an await and a task are four different things.
async defcreates a coroutine function; calling it does not run the body, it builds an object and returns;awaitstarts 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
awaitturnsif is_allowed(user)intoif, which is true for any data — verified:chuck, who is not on the list, passes the check. Onetime.sleepinside 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. Andgatherpropagates 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 askedtask.exception(). - Beyond that: numbers, versions and boundaries. The
asyncfunction's own type is the ordinaryfunction, differing only by theCO_COROUTINEflag on its code. TheRuntimeWarningabout "never awaited" is printed at garbage collection, not at the moment of the mistake, and even-W error::RuntimeWarningdoes not stop execution, is not caught byexceptand does not change the exit code — verified, the exit code is0.TaskGroup(3.11) works differently: it cancels the siblings and raises both exceptions as oneExceptionGroup. The price of the machinery itself (3.13.7): a plain call is 60.0 ns, anawaitof a coroutine 119.4 ns — twice as much;await asyncio.sleep(0)is 2.42 µs — forty times.
In fact
- On its own it makes code slower. An
awaitof 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,asynciospeeds up nothing. - 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::RuntimeWarningdoes not stop it — the warning is raised in a finaliser, where exceptions are swallowed. Verified:except RuntimeWarningdoes not fire, execution runs to the end, the exit code is0. - It blocks exactly the same.
awaitis where a coroutine yields control; an ordinary call is not such a place. Measured: five tasks withtime.sleep(0.1)insidegathertook 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. - Indistinguishable by total time, opposite in consequence. Sequential
await— 0.51 s and 51 heartbeats;gatherwith 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. - 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 throughtask.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. - The difference is behaviour on error.
TaskGroupcancels the remaining tasks and raises every exception as oneExceptionGroup;gathercancels nobody and raises the first. Nor is the swap mechanical:gatherreturns results in argument order, whileTaskGroupreturns nothing — results are taken from the task objects. - A second
awaitof the same object givesRuntimeError: cannot reuse already awaited coroutineon all four versions. What is reused is the function that creates the coroutine, not the coroutine. That is why aretryhelper takes a function rather than a ready object. - 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.
By version
- 3.5
- PEP 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
RuntimeWarningfor a forgottenawaitarrives at the same time — already tied to garbage collection, so with both caveats this lesson demonstrates.< - 3.11
TaskGroupandasyncio.timeoutarrive, and the documentation prefers both immediately: “For new code this is recommended over usingcreate_task()andgather()directly” and “recommended over usingwait_for()directly”. PEP 654 lands in the same release withExceptionGroupandexcept*, without whichTaskGroupwould have had no way to raise two errors at once.<- 3.13
TaskGroup.create_task()on an inactive group now closes the coroutine it was given, “which prevents aRuntimeWarningabout 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.14
create_taskaccepts arbitrary keyword arguments, andnameandcontextstop being special — irrelevant to application code, relevant to a custom task factory.capture_call_graph()andprint_call_graph()arrive: a supported way to see what a hung task is waiting on.<
What is covered
- Base: four things and a queue of work
- Mechanism 1: the call does not run the body
- Mechanism 2: the bug that never crashes
- Mechanism 3: one blocking call stops everything
- Mechanism 4: where gather and TaskGroup part ways
- Mechanism 5: a task nobody references
- Mechanism 6: cancellation is control flow, not an error
- Deeper: what await costs
- Version history
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
- What measured this
Sources & further reading
8 SOURCES
- 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/
- 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
- 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/
- 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
- 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
- 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
- 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
- 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