Structured concurrency in asyncio is not sugar over gather. It is a protocol built on top of cancellation, and every layer of your code participates in it. TaskGroup shuts a group down by cancelling its children. asyncio.timeout implements a deadline by cancelling the task it is running in. Both of them require that CancelledError travels through your code untouched. One except BaseException around an await, one un-shielded await in a finally, and the whole thing degrades into the kind of bug that only shows up under load in production. Python 3.15 finally closes the last obvious gap in the API with TaskGroup.cancel(); per PEP 790, 3.15.0rc1 shipped on 4 August 2026 and the final release is scheduled for 1 October 2026 (the rest of that release’s breaking changes are covered in the Python 3.15 migration guide).
One cancellation counter, shared by both primitives
asyncio.TaskGroup and asyncio.timeout() were both added in Python 3.11. So were Task.cancelling() and Task.uncancel(), and those two are the ones you actually need to understand — they are the bookkeeping that makes the other two composable.
Task.cancel() does not kill anything. It increments a counter and arranges for a CancelledError to be thrown into the coroutine on the next loop iteration. Task.cancelling() returns the number of cancel() calls minus the number of uncancel() calls. That counter is how nested structured-concurrency scopes tell "I caused this cancellation" apart from "someone outside is cancelling me and I must get out of the way."
The CPython docs are unusually blunt about the consequence:
The asyncio components that enable structured concurrency, like
asyncio.TaskGroupandasyncio.timeout(), are implemented using cancellation internally and might misbehave if a coroutine swallowsasyncio.CancelledError.
Treat "don't swallow CancelledError" as an interface contract, not a style preference. CancelledError has subclassed BaseException since Python 3.8 precisely so that ordinary except Exception: handlers let it through — which means the danger is concentrated in the handlers that deliberately catch more than that.
Where timeout converts CancelledError into TimeoutError
The whole behaviour of asyncio.timeout() hinges on a single condition in Lib/asyncio/timeouts.py:
if self._state is _State.EXPIRING:
self._state = _State.EXPIRED
if self._task.uncancel() <= self._cancelling and exc_type is not None:
# Since there are no new cancel requests, we're
# handling this.
if issubclass(exc_type, exceptions.CancelledError):
raise TimeoutError from exc_val
elif exc_val is not None:
self._insert_timeout_error(exc_val)
if isinstance(exc_val, ExceptionGroup):
for exc in exc_val.exceptions:
self._insert_timeout_error(exc)
self._cancelling is a snapshot of the task's cancellation count taken in __aenter__. On exit the context manager calls uncancel() and compares. If the count did not climb above the snapshot, no new external cancellation arrived while the block was running, so this cancellation belongs to the timeout and gets rewritten as TimeoutError. If it did climb, the timeout stays out of the way and lets the outer cancellation propagate.
Three consequences fall out of that code, and all three bite real systems.
TimeoutError can only be caught outside the async with. Inside the block, what is flowing is a CancelledError. An except TimeoutError nested inside the timeout block is dead code. The docs state this explicitly, and it is the single most common misreading of the API.
asyncio.timeout() only works inside a Task. __aenter__ calls current_task() and raises RuntimeError("Timeout should be used inside a task") if there isn't one. You hit this when a helper is awaited from something that isn't a Task — hand-rolled future chains, some test harnesses, or code driving a coroutine through loop.run_until_complete on a bare coroutine in older layouts.
Only a CancelledError becomes a TimeoutError. Any other exception goes through _insert_timeout_error, which merely splices a TimeoutError into the exception's __context__ chain. The exception that actually propagates is the original one. This is the setup for the next section.
Nesting timeout around a TaskGroup will leak past except TimeoutError
This is the shape almost every production codebase converges on:
try:
async with asyncio.timeout(5):
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_a())
tg.create_task(fetch_b())
except TimeoutError:
log.warning("fan-out timed out")
When the deadline fires and every child responds to cancellation cleanly, the group re-raises CancelledError, the timeout rewrites it to TimeoutError, and the handler works. That is the happy path, and it is the one you will see in every test you write.
Now suppose one child raises something else on its way out — a ConnectionResetError from a socket teardown, a RuntimeError from a half-initialised pool, anything at all that isn't CancelledError. TaskGroup collects it and raises an ExceptionGroup instead of a CancelledError. The timeout's __aexit__ now takes the _insert_timeout_error branch: a TimeoutError is spliced into the __context__ chain of the group itself and of each sub-exception, and the ExceptionGroup propagates untouched. Your except TimeoutError never fires. The ExceptionGroup escapes to whatever is above — often an unhandled-exception logger, or worse, the ASGI server's generic 500 path.
So write both arms:
try:
async with asyncio.timeout(5):
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_a())
tg.create_task(fetch_b())
except TimeoutError:
... # clean deadline, everyone unwound properly
except* Exception as eg:
... # a child failed, possibly while being cancelled
Better still: if what you actually want is "each child gets at most N seconds", put the timeout inside each child coroutine. Per-child deadlines are easier to reason about, produce better error attribution, and sidestep this interaction entirely. Reserve the outer timeout for a genuine wall-clock budget on the whole fan-out — and when you use it, accept that you need the except* arm.
The only correct way to swallow a cancellation
Sometimes you really do need to absorb a cancellation — an idempotent compensating write that must complete, a lease that must be released through a network call. Catching CancelledError is only half the job; you must also clear the cancellation state, or every enclosing TaskGroup and timeout will make wrong decisions based on a counter that no longer matches reality:
async def with_compensation():
try:
return await work()
except asyncio.CancelledError:
if not can_absorb():
raise
asyncio.current_task().uncancel() # without this, outer scopes miscount
return await compensate()
There is a version-dependent trap here. Python 3.13 changed uncancel() to rescind pending cancellation requests when the count reaches zero, resetting the task's internal _must_cancel flag. On 3.12 and earlier there was no such rescind step, so a task could call uncancel() and then immediately eat another CancelledError from a cancel() that had already been arranged but not yet delivered. If you maintain a library that supports 3.11 through 3.15, this difference is real and you cannot paper over it with a try/except.
finally blocks deserve their own warning. Once a task is in a cancelled state, an await inside finally will typically take another CancelledError immediately, leaving your cleanup half-done. asyncio.shield(cleanup()) protects the shielded task from being cancelled, but the awaiting coroutine still receives CancelledError at the await — the cleanup keeps running, and you stop watching it. If a cleanup genuinely must run to completion and be observed, hand it to a task outside the group (a long-lived "janitor" group, or a shutdown registry that is drained before the process exits) rather than trying to make finally bulletproof. Long-lived connections hit this constantly, since a client disconnect cancels the handler mid-stream; the FastAPI SSE production guide works through that case.
Migrating off gather and wait
Neither gather nor wait is structured, and their documented defaults leak tasks. This is not a subtlety — it is the behaviour written down in the reference:
gather(*aws)with the defaultreturn_exceptions=False: the first exception propagates immediately to the awaiter, and the remaining awaitables are not cancelled. They keep running in the background after yourexceptblock has finished handling the error. Whatever resources they hold stay held; whatever side effects they were going to perform still happen — inside a web framework the same leak surfaces as orphaned background work, which the FastAPI background task guide covers in detail.gather(*aws, return_exceptions=True): exceptions are collected into the result list as values. That includesCancelledError, which means this call is itself a cancellation swallower. An enclosingasyncio.timeoutcan and will draw the wrong conclusion from it.asyncio.wait(...): on timeout it does not cancel anything — unfinished futures are simply returned in thependingset, and noTimeoutErroris raised. Ifwait()itself is cancelled, the futures passed to it are not cancelled either. It is an observation tool, not a concurrency manager. Since 3.11, passing bare coroutine objects towait()is forbidden; you must wrap them in tasks first.
| Old | New |
|---|---|
await gather(a(), b()) |
async with TaskGroup() as tg: tg.create_task(...) |
gather(..., return_exceptions=True) then inspect each result |
except* SomeError as eg: |
await wait_for(x(), 5) |
async with asyncio.timeout(5): await x() |
wait(..., return_when=FIRST_COMPLETED) racing |
3.15: tg.cancel() (below) |
manual for t in pending: t.cancel() |
nothing — the group does it |
wait_for has been reimplemented on top of asyncio.timeout since 3.12, so the two now share cancellation semantics; the rewrite was deliberately not backported. Since 3.11 it raises the builtin TimeoutError, and asyncio.TimeoutError is just an alias for it.
The migration hazard that catches people is the exception type. TaskGroup raises ExceptionGroup (or BaseExceptionGroup if a BaseException is in the mix), so every except ValueError above your old gather call silently stops matching. You have two options and should pick one deliberately: convert the handlers to except*, or handle errors inside each child coroutine so the group only ever sees success. For request-scoped fan-out, handling inside the child is usually the better design — it keeps error attribution attached to the operation that failed.
TaskGroup.cancel() in 3.15
Until 3.15 there was no supported way to end a task group early without an exception. "First result wins", "stop when the queue drains", "shut down on a signal" all required the same unpleasant dance: define a private exception, raise it from a child, and suppress it outside the group. It works, it is ugly, and it is easy to get wrong when more than one child can finish first.
TaskGroup.cancel() was added in 3.15 (gh-127214, proposed by John Belmonte, merged by Guido van Rossum on 24 April 2026):
async def first_wins(urls):
result = None
async with asyncio.TaskGroup() as tg:
async def run(url):
nonlocal result
result = await fetch(url)
tg.cancel()
for url in urls:
tg.create_task(run(url))
return result
The documented semantics:
cancel()callscancel()on every task in the group that is not yet done, and on the group's own body (the parent task).- The context manager exits without
asyncio.CancelledErrorbeing raised. This is a non-exceptional early exit, not an error path. - It is idempotent and safe to call after the group has already exited.
- Calling it before entering the group cancels the group on entry. That is the supported pattern for handing an unused
TaskGroupinstance (or its boundcancelmethod) to some other component so it can shut the group down remotely.
The design point worth internalising: this is deliberately not the same as trio's nursery.cancel_scope.cancel(). asyncio's version calls uncancel() on the parent task as the group exits, so the cancellation does not bleed past the group boundary. The name was contested during review — stop() was proposed on the grounds that cancel() implies a CancelledError will be raised — and cancel() won for consistency with trio and anyio. If you are used to those libraries, remember that the propagation behaviour differs even though the name matches.
If you are still on 3.11–3.14, the sentinel-exception workaround remains the correct approach. Do not reach into tg._abort() or other private attributes to fake it; the internal state machine assumes the parent task's cancellation count is managed by __aexit__, and bypassing it produces exactly the counter corruption this article is about.
Known sharp edges as of 3.15
gh-134471 — asyncio.timeout(0) swallows a prior cancellation. If the enclosing task was already cancelled before the block is entered, a zero-delay timeout can capture that unrelated CancelledError and convert it to TimeoutError, so the task effectively ignores the cancellation and keeps going. The root cause is that the _cancelling snapshot cannot see a _must_cancel flag that was set in the same loop iteration. The issue was filed on 21 May 2025, affects 3.11 through main, and was still open at the time of writing. Practical rule: asyncio.timeout(0) is not a "cancel immediately" switch — that is what Task.cancel() is for.
Eager tasks (fixed). Cancellation could leak out of a TaskGroup when asyncio.eager_task_factory was in use (gh-128588). The fix removed the eager-task optimisation that introduced the incorrect cancellations, and was backported to 3.12 and 3.13. Eager tasks remain a genuine semantic change in their own right — a coroutine that completes without blocking is never scheduled on the loop at all — so if you enable the factory globally, do it deliberately and on a current patch release.
Introspection. Python 3.14 (released 7 October 2025) added python -m asyncio ps PID and python -m asyncio pstree PID, plus the programmatic asyncio.capture_call_graph() / asyncio.print_call_graph(). For diagnosing a hung TaskGroup in a live process, pstree renders the await graph including TaskGroup.__aexit__ frames, so you can see immediately whether the group is waiting on a child or the child is waiting on something else — far faster than reconstructing it from logs.
Review checklist
- Grep for
except BaseExceptionand bareexcept:around anyawait. Each one must re-raiseCancelledError. - Any
except asyncio.CancelledErrorthat does not re-raise must callasyncio.current_task().uncancel(), with a comment explaining why absorbing it is safe. - Wherever
asyncio.timeoutwraps aTaskGroup, handle bothTimeoutErrorandexcept*. - Treat
gather(..., return_exceptions=True)as debt — it demotesCancelledErrorto data. - Prefer per-operation
asyncio.timeoutinside child coroutines over one outer deadline. - Default to
TaskGroup+asyncio.timeout; keepgatheronly for fan-outs that cannot partially fail.