When one task inside an asyncio.TaskGroup raises, what happens to its siblings and what does the block raise?
answer
- One failure ends the whole block
- The others are asked to stop
- The exception type at the boundary changes
- Cancelled siblings are not errors
- except* selects out of the group
basics
~20 sThe first non-cancellation failure makes the group cancel every other task it owns and wait for them; the async with block then raises an ExceptionGroup holding the real errors, which a plain except ValueError will not catch.
solid answer
~40 sAs soon as a child finishes with an exception other than `asyncio.CancelledError`, `asyncio.TaskGroup` calls `cancel()` on its remaining tasks, waits for all of them to settle, and then raises an `ExceptionGroup` from the block's exit. Cancelled siblings contribute nothing to that group — it reports the cause, not the collateral damage — and usually it holds one exception, though simultaneous failures can put several in it. An exception raised by the block body itself is treated the same way and lands in the same group; `KeyboardInterrupt` and `SystemExit` are re-raised directly instead of wrapped. The practical consequence is that `except ValueError` around the block stops firing: you need `except* ValueError`, which binds a sub-group of the matching errors.
code
python · 20 linesimport asyncio
async def convert(page: int) -> str:
await asyncio.sleep(0.5)
return f"page-{page}"
async def bad_page() -> str:
await asyncio.sleep(0.05)
raise ValueError("page 4 is corrupt")
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
slow = tg.create_task(convert(1))
tg.create_task(bad_page())
except* ValueError as eg:
print("group:", type(eg).__name__, eg.exceptions)
print("sibling cancelled:", slow.cancelled())
asyncio.run(main())go deeper
Remember the two-part answer: the other tasks in the group get cancelled, and the block raises a grouped exception rather than the original one, so ordinary except SomeError no longer matches.
Explain the sequence precisely — cancel siblings, wait for them, then raise ExceptionGroup — plus which exceptions are included: real failures and the body's own, but not the cancellations the group itself caused.
Show you have debugged this: read the inner tracebacks rather than the machinery frames, handle a multi-exception group, and know that a child blocked in synchronous code delays the whole abort because cancellation is cooperative.
Own the policy: whether services let groups fail fast, how grouped failures are logged and alerted without losing the inner causes, and what to do about handlers across the codebase that silently stopped matching after adoption.
### The rule Inside `async with asyncio.TaskGroup() as tg:`, the moment a child task finishes with an exception other than `asyncio.CancelledError`, the group switches to aborting: it calls `cancel()` on every other task it owns, waits for them all to settle, and then — as the `async with` block exits — raises an `ExceptionGroup` containing the errors that really happened. A plain `except ValueError` around the block does **not** catch that; `ExceptionGroup` is not a `ValueError`. You either catch the group itself or use `except*`: ```python try: async with asyncio.TaskGroup() as tg: tg.create_task(convert(1)) tg.create_task(bad_page()) except* ValueError as eg: for err in eg.exceptions: log(err) ``` `except* ValueError` selects the `ValueError`s out of the group and binds `eg` to a *sub-group* holding just those; anything not matched keeps propagating. This is the single most common surprise when a team first adopts task groups: exception handlers that used to fire silently stop firing, because the exception type at the boundary changed shape. ### Which exceptions end up in the group * **Real child failures.** Usually there is exactly one, because the first failure cancels the rest. You can get several when tasks fail in the same pass of the loop before the cancellation lands, so never write code that assumes `eg.exceptions` has length one. * **Cancelled siblings do not contribute.** A task the group cancelled ends in `CancelledError`, and the group treats that as "did as it was told", not as an error. It is not added to the group, so the group reports the *cause*, not the collateral damage. * **An exception raised by the block body.** If the code between `create_task` calls raises, the group treats it exactly like a child failure — remaining children are cancelled and the body's exception is delivered inside the same `ExceptionGroup`. * **`KeyboardInterrupt` and `SystemExit` are special.** The group still cancels and waits, but then re-raises that base exception directly rather than wrapping it, so Ctrl-C still reads like Ctrl-C. If some other `BaseException` is in play the group raises a `BaseExceptionGroup` instead of an `ExceptionGroup`. ### Why cancellation, not just aggregation The cancellation half is the point of the design. If one of a fan-out's legs has already failed, the overall operation is going to fail, and every second the siblings keep running is wasted CPU, wasted connections, and — worse — side effects applied for a request that is already doomed. Fail-fast plus "nothing outlives the block" is what makes a group safe to call from a request handler. Cancellation is cooperative, though, and this is where seniority shows. `Task.cancel()` throws `CancelledError` in at the child's next suspension point. A child that is blocked in a synchronous call — a CPU loop, a blocking driver call — will not notice until it returns, and the group waits for it. So "the group cancels the siblings" is a promise about *asking*, not about instant termination; a group is only as responsive as its most cooperative child. ### Reading a failure in production The traceback for a group failure is nested: the outer frames show `__aexit__` raising the `ExceptionGroup`, and each contained exception is printed underneath with its own traceback. Read the *inner* ones — the outer frames are the machinery, not the bug. When you re-raise from an `except*` handler, remember you are holding a sub-group, not the original exception, so wrapping logic that expects a single exception object needs updating. ### Version note Task groups, `ExceptionGroup`, `BaseExceptionGroup` and `except*` all arrived together in **Python 3.11**. On 3.10 and earlier none of this exists and the nearest equivalent is a manual create-tasks-then-cancel-on-error pattern that raises the first exception only, losing the others. All of the behaviour above is current on 3.14. ### Catching it without losing information Two handler shapes are worth knowing. `except*` runs its body once per matching type and gives you a sub-group, so `for err in eg.exceptions:` is the normal way to log every cause; several `except*` clauses on one `try` can each claim their own type from the same group, and anything unclaimed propagates onward still grouped. Alternatively `except BaseExceptionGroup as eg:` catches the whole thing at a boundary where you only want to log and re-raise. What you should not do is flatten the group into "the first exception" and discard the rest: on a wide fan-out the discarded ones are often the more interesting failures, and the whole reason 3.11 introduced a container type was that a single-exception channel could not carry them.
- Why doesn't the CancelledError from a cancelled sibling appear in the ExceptionGroup?Because the group caused that cancellation itself; a child that stops when told did nothing wrong. Treating it as an error would bury the real cause under noise proportional to the fan-out width. The group therefore filters `asyncio.CancelledError` out and reports only exceptions that were not its own doing — so what you read is the failure, not the cleanup.
- Can the group contain more than one exception, given the first failure cancels the rest?Yes. Cancellation is delivered at each child's next suspension point, so tasks that fail in the same pass of the event loop, before the cancellation reaches them, all land in the group. Never write handler code that assumes `eg.exceptions` has exactly one element — iterate it.
- What happens if the code inside the block body raises before all tasks are created?The group treats it exactly like a child failure: already-created tasks are cancelled and awaited, and the body's exception is delivered inside the same `ExceptionGroup` raised by the block's exit. Tasks that were scheduled but had not yet started simply never run — they are cancelled before their first step.
- Does the group guarantee the siblings actually stopped when it re-raises?It guarantees they have *settled*, not that they stopped promptly. `Task.cancel()` raises `CancelledError` at the child's next suspension point, so a child stuck in a synchronous call — a CPU loop or a blocking driver call — is not interrupted, and the group waits for it. A group is only as responsive as its most cooperative child.
saying these in an interview costs you the question
- Expects a plain except ValueError around the block to catch it
- Says the siblings keep running to completion after a failure
- Thinks cancelled siblings add CancelledError to the group
- Assumes the group always holds exactly one exception
- Believes cancel() terminates a child instantly
- Reads only the outer frames of the nested traceback