TaskGroup and structured concurrency
Article.
asyncio.TaskGroup (3.11) is the safer default for running coroutines
concurrently; gather is not deprecated, and the docs present TaskGroup as
the alternative with stronger guarantees. The reason is not ergonomics. It is
that structured concurrency guarantees every exception has somewhere to go
— which the older primitives do not.
Once you can say where an exception ends up in each case, every rule here stops being a rule and becomes obvious.
The mechanism: where does the exception go
Four ways to run a coroutine, four different fates for a failure inside it.
# 1. awaited directly - propagates to you, immediately
await fetch()
# 2. gather - the FIRST exception propagates; siblings keep running
await asyncio.gather(fetch_a(), fetch_b(), fetch_c())
# 3. create_task with nobody awaiting - stored on the Task object
asyncio.create_task(fetch()) # nothing observes the result
# 4. TaskGroup - collected, siblings cancelled, raised as a group
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch())Measured: one failure, two primitives
Three children, one raising at 0.2 s, the others finishing at 0.6 s and 1.0 s, on CPython 3.14.5:
gather TaskGroup
0.2s gather raised 0.2s b cancelled
0.6s b finished (still ran) 0.2s a cancelled
1.0s a finished (still ran) 0.2s raised ExceptionGroupgather reported the failure and let the siblings run to completion in the
background, holding whatever they held. TaskGroup cancelled them at the
moment of failure, waited for their cleanup, and only then raised. Cancelling
the gather future after it has raised does not stop the siblings either: it
is already done, so the cancel is a no-op.
Case 3 is the dangerous one. An unawaited task that raises does not crash anything. The exception is stored on the Task, and Python only complains when the task is garbage collected:
Task exception was never retrieved
future: <Task finished coro=<fetch() done>
exception=ValueError('boom')>That message arrives whenever the GC gets round to it — seconds later, or at interpreter shutdown, or never if the process is killed first. So the failure is real, silent, and detached from the code that caused it.
The task that vanishes
Worse than a lost exception. The event loop keeps only a weak reference to tasks, so a task with no strong reference anywhere can be collected while still running:
# The task may be garbage collected mid-flight. It just stops.
asyncio.create_task(send_metrics())
# Correct: hold a reference until it completes.
_background: set[asyncio.Task] = set()
task = asyncio.create_task(send_metrics())
_background.add(task)
task.add_done_callback(_background.discard)That five-line idiom exists purely because of the weak reference, and its absence produces work that silently sometimes does not happen — which is the hardest class of bug to even notice.
What TaskGroup guarantees
async with asyncio.TaskGroup() as tg:
a = tg.create_task(fetch_a())
b = tg.create_task(fetch_b())
c = tg.create_task(fetch_c())
# past this line, all three are finished
print(a.result(), b.result(), c.result())The block does not exit until every task has finished. If any task raises, its
siblings are cancelled and the failures are collected. Nothing leaks,
because nothing can outlive the async with.
That is what “structured” means: a task’s lifetime is bounded by a syntactic block, exactly as a local variable’s is. You can always answer “where might this still be running” by looking at the indentation.
Trio invented the idea as a nursery; anyio calls it a task group; 3.11 brought
it into the standard library.
How it works
- On the first failure other than
CancelledError, the group cancels every child and the task running theasync with, so the body stops at its nextawaittoo. __aexit__waits for every child, then callsuncancel()on the parent task to undo its own cancellation, which is why a TaskGroup composes insideasyncio.timeout()without confusing whose cancellation is whose.KeyboardInterruptandSystemExitfrom a child are re-raised as they are, not wrapped in anExceptionGroup.create_taskon a group that has exited raisesRuntimeErrorand closes the coroutine, so a task cannot be added to a scope that no longer exists.- Eager start. With the eager task factory (3.12) or, since 3.14,
tg.create_task(coro, eager_start=True), a child runs insidecreate_taskuntil its first real suspension, which changes ordering and where an early exception surfaces.
except* and ExceptionGroup
Because several tasks can fail at once, TaskGroup raises an ExceptionGroup,
and PEP 654 added syntax to match inside one:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_a())
tg.create_task(failing())
tg.create_task(slow()) # cancelled when failing() raises
except* ValueError as eg:
for e in eg.exceptions:
log.error("ValueError in subtask", exc_info=e)
except* TimeoutError as eg:
...This is the migration trap. Existing code written as
except ValueError: around a gather will not catch a ValueError raised
inside a TaskGroup, because what propagates is an ExceptionGroup containing
one. The handler silently stops matching, and the error escapes to whatever is
above it.
Cancellation
Cancel the surrounding coroutine and every task in the group is cancelled,
given the chance to run its finally blocks, and awaited before the group
re-raises. CancelledError is a BaseException, so except Exception does
not swallow it, and code that catches it must re-raise; the details are in
Cancellation, timeouts and error handling in asyncio.
Stopping a group early
Python 3.15 adds TaskGroup.cancel(). On 3.14 and earlier, the documented
recipe is to raise a private exception inside the group and swallow it
outside:
class Done(Exception): ...
async def first_good(candidates):
found = None
async def try_one(c):
nonlocal found
result = await check(c)
if good(result):
found = result
raise Done # cancels the rest of the group
try:
async with asyncio.TaskGroup() as tg:
for c in candidates:
tg.create_task(try_one(c))
except* Done:
pass
return foundPartial failure: tolerate some errors
TaskGroup’s all-or-nothing is wrong when, say, 5,000 URLs must be fetched with 20 in flight and a few failures are acceptable. Make each child return its error instead of raising it, and keep the group for its lifetime guarantee:
async def safe_fetch(url, sem):
async with sem:
try:
return url, await fetch(url)
except Exception as e: # an ordinary failure becomes a result
return url, e
sem = asyncio.Semaphore(20)
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(safe_fetch(u, sem)) for u in urls]
failures = [(u, r) for u, r in (t.result() for t in tasks) if isinstance(r, Exception)]gather(..., return_exceptions=True) gives the same result list in one line.
Two differences: it also puts a CancelledError in the list for any child
that was cancelled, so check for it, and it gives no lifetime guarantee for
work started elsewhere. Cancellation still propagates through safe_fetch,
because CancelledError is not an Exception.
Picking the primitive
| Primitive | Use it when | Watch out for |
|---|---|---|
await coro |
you need the result now | no concurrency at all |
TaskGroup |
default for N concurrent things | raises ExceptionGroup |
gather(*coros) |
quick all-or-nothing fan-out | siblings run on after a failure |
gather(return_exceptions=True) |
partial failure is fine | you must inspect every result |
create_task |
you need a handle to cancel later | weak ref; keep a strong one |
asyncio.timeout(n) |
bound a whole block | catch TimeoutError at the boundary |
as_completed |
act on results as they land (an async iterator since 3.13) | does not cancel the losers |
asyncio.Queue |
producer/consumer with backpressure | unbounded unless you set maxsize |
to_thread(fn) |
a blocking call you cannot avoid | see the thread-offload note |
Semaphore |
cap concurrency on a dependency | acquire inside the task |
Where it breaks in production
The semaphore in the wrong place
The most common concurrency-limiting bug, and it looks correct:
# WRONG - limits how fast tasks are created, not how many run.
async with asyncio.TaskGroup() as tg:
for url in urls:
async with sem: # released immediately
tg.create_task(fetch(url))create_task schedules and returns at once, so the semaphore is acquired and
released a thousand times in a few milliseconds and a thousand fetches run
concurrently. The limit does nothing: measured with an in-flight counter, the
wrong placement peaked at 1,000 concurrent fetches, the right one at
10.
# RIGHT - the semaphore is held for the duration of the work.
async def guarded(url):
async with sem:
return await fetch(url)
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(guarded(url))One blocking call stalls everything
time.sleep, requests, a sync database driver or a heavy CPU loop stops every
coroutine in the process, because they all share one thread. It presents as
latency rising with concurrency while CPU sits idle — see
CPU-bound versus I/O-bound.
Unbounded fan-out
TaskGroup will happily start fifty thousand tasks. Each is cheap, and fifty
thousand concurrent connections to one dependency is a denial-of-service attack
you wrote yourself. Bound it with a semaphore, or process in batches.
as_completed leaving work running
Taking the first result and breaking out leaves the losers running, still holding connections:
for coro in asyncio.as_completed(candidates):
result = await coro
if good(result):
break # the rest are still runningCancel them explicitly, or run them in a TaskGroup and stop it early as shown above (TaskGroup.cancel() on 3.15).
The trade not taken
gather is not deprecated and does not need replacing everywhere. For a
one-line fan-out where all-or-nothing is genuinely the semantic you want, it is
shorter and clearer:
user, prefs, flags = await asyncio.gather(get_user(), get_prefs(), get_flags())The case for TaskGroup is when failure handling matters — when a sibling
must not keep running after another fails, when tasks are created dynamically
inside the block, or when you need the guarantee that nothing outlives the
scope. On a happy-path fan-out of three independent reads, gather is fine and
the ExceptionGroup migration cost is real.
Related
Interview angle 7
- “Why TaskGroup instead of gather?” — because it bounds every task’s
lifetime to a syntactic block, so siblings are cancelled when one fails and
nothing can leak past the
async with.gatherpropagates the first exception and leaves the others running invisibly.- Follow-up: “Prove it.” — one child failing at 0.2 s:
gatherraised at 0.2 s while its siblings finished at 0.6 s and 1.0 s; a TaskGroup cancelled them at 0.2 s and raised after their cleanup. - Follow-up: “What actually happens to an exception in an unawaited task?” — it is stored on the Task object and only surfaces as “Task exception was never retrieved” when the task is garbage collected, which may be much later or never. The failure is real and silent.
- Follow-up: “Prove it.” — one child failing at 0.2 s:
- “What is the bug in
asyncio.create_task(send_metrics())?” — the loop holds only a weak reference, so the task can be garbage collected while running and simply stop. Keep it in a set and discard on done. - “What breaks when you migrate gather to TaskGroup?” — exception handlers.
A TaskGroup raises an
ExceptionGroup, soexcept ValueErrorno longer matches and the error escapes. You needexcept*. - “Your semaphore does not limit anything.” — it was acquired around
create_task, which returns immediately, so it is released before the work starts. Acquire it inside the coroutine so it is held for the duration.- Follow-up: “How would you notice?” — concurrency against the dependency matches the number of URLs rather than the semaphore value; the limit has no effect on observed throughput or on the dependency’s rate-limit errors.
- “Should you ever catch
CancelledError?” — to clean up, then re-raise. Swallowing it breaks the contract, because the caller believes the task stopped. Note it inherits fromBaseException, soexcept Exceptiondoes not catch it, which is deliberate. - “5,000 URLs, 20 in flight, tolerate failures. How?” — a semaphore of 20
held inside each child, children that return their exception instead of
raising it, and a TaskGroup around them; or
gather(return_exceptions=True). - “Is
gatherobsolete?” — no. For an all-or-nothing fan-out of a few independent reads it is shorter and clearer. TaskGroup earns its place when failure handling matters, tasks are spawned dynamically, or you need the no-leak guarantee.