If one coroutine in asyncio.gather() raises, the exception propagates to your await, but the sibling tasks are not cancelled. They keep running in the background, still holding connections and still writing. Since Python 3.11, asyncio.TaskGroup fixes this: the first failure cancels the siblings, and you handle the result as an ExceptionGroup. Use gather(..., return_exceptions=True) only when you genuinely want every result, failures included.

What actually happens when gather fails?

The symptom usually shows up as a "ghost" side effect: a request handler returns a 500, yet a database row or an outbound API call from a sibling coroutine still lands a second later. Here is a minimal reproduction:

import asyncio async def worker(name: str, delay: float, fail: bool = False) -> None: try: await asyncio.sleep(delay) if fail: raise RuntimeError(f"{name} failed") print(f"{name} finished") except asyncio.CancelledError: print(f"{name} cancelled") raise async def main() -> None: try: await asyncio.gather( worker("a", 0.1, fail=True), worker("b", 0.5), ) except RuntimeError as exc: print("caught:", exc) await asyncio.sleep(1) # keep the loop alive to observe "b" asyncio.run(main()) 

Output:

caught: a failed b finished 

Task b was never cancelled. The caller already moved on to its error path while b completed its work. The asyncio documentation describes this behavior for the default return_exceptions=False: the first exception is propagated to the awaiter, and the other awaitables in the sequence are not cancelled.

Takeaway: gather reports the first failure but does not clean up after it.

What do people try first (and why it falls short)?

Three dead ends come up repeatedly:

  • Wrapping the gather in try/except and cancelling the gathered future. Cancelling the future returned by gather does cancel the children, but you only get that handle if you stored it, and by then the failure has already been raised and the siblings have had time to do damage.
  • Adding return_exceptions=True and forgetting to check the results. Exceptions are now returned as ordinary list items, so nothing raises at all. A failed task becomes a silently ignored object in a list.
  • Bumping timeouts. A timeout around gather cancels the whole thing, but it does not help when the failure is fast and the siblings are slow.

Takeaway: each workaround either needs manual bookkeeping or turns a loud failure into a quiet one.

How does TaskGroup change this?

asyncio.TaskGroup (Python 3.11 and later) gives structured concurrency: the async with block does not exit until every task in it is done, and if any task fails, the rest are cancelled.

import asyncio async def worker(name: str, delay: float, fail: bool = False) -> None: try: await asyncio.sleep(delay) if fail: raise RuntimeError(f"{name} failed") print(f"{name} finished") except asyncio.CancelledError: print(f"{name} cancelled") raise async def main() -> None: try: async with asyncio.TaskGroup() as tg: tg.create_task(worker("a", 0.1, fail=True)) tg.create_task(worker("b", 0.5)) except* RuntimeError as eg: for exc in eg.exceptions: print("caught:", exc) asyncio.run(main()) 

Output:

b cancelled caught: a failed 

Two things trip people up here. First, errors always arrive wrapped in an ExceptionGroup, even if only one task failed, so a plain except RuntimeError: will not catch it; you need except* (also 3.11+). Second, the try/except* has to wrap the async with, not sit inside it, because the group raises when the block exits.

The cost is real: you cannot collect partial results from the survivors, because they were cancelled. If you want "run all, tell me which failed", TaskGroup is the wrong shape.

Takeaway: TaskGroup trades partial results for guaranteed cleanup, and wraps every error in an ExceptionGroup.

When is return_exceptions=True the right choice?

When the tasks are independent and a partial result is useful, such as fetching 50 URLs where 3 failing should not discard the other 47:

import asyncio async def fetch(i: int) -> int: if i % 4 == 0: raise ValueError(f"item {i} is bad") await asyncio.sleep(0.01) return i * 10 async def main() -> None: results = await asyncio.gather(*(fetch(i) for i in range(1, 9)), return_exceptions=True) ok = [r for r in results if not isinstance(r, BaseException)] failed = [r for r in results if isinstance(r, BaseException)] print("ok:", ok) print("failed:", [str(e) for e in failed]) asyncio.run(main()) 

Always split the results explicitly, as above. One subtlety: with return_exceptions=True, a CancelledError from a child can also appear in the results list as a value, so check against BaseException, not just Exception.

Need Use Siblings on failure Error shape
All-or-nothing, clean shutdown TaskGroup Cancelled ExceptionGroup
Best effort, want every outcome gather(return_exceptions=True) Keep running to completion Values in a list
Legacy code on Python 3.10 or older gather plus manual cancel Keep running First exception only
Background work that must outlive the caller create_task with a stored reference Independent Handled in the task

Takeaway: choose by whether a partial result is worth more than a guaranteed stop.

Why do fire-and-forget tasks disappear mid-run?

A related trap: asyncio.create_task(coro()) without keeping the returned task. The event loop holds only a weak reference to tasks, so an unreferenced task can be garbage-collected before it finishes, sometimes producing Task was destroyed but it is pending! in the logs. Keep a strong reference and drop it when the task is done:

import asyncio background: set[asyncio.Task] = set() def spawn(coro) -> asyncio.Task: task = asyncio.create_task(coro) background.add(task) task.add_done_callback(background.discard) return task 

If an exception in such a task is never retrieved, you will see Task exception was never retrieved at garbage collection time. Add a done callback that logs task.exception() if you want failures visible immediately.

Takeaway: an unreferenced task is a task you have agreed to lose.

What if you are stuck below Python 3.11?

The taskgroup and exceptiongroup backport packages exist on PyPI, but check their maintenance status before depending on them. Alternatively, if you already use AnyIO or Trio, both provide task groups with the same cancel-siblings semantics. If you want that behavior across Python versions without writing your own plumbing, AnyIO is the library that gives you task groups on top of asyncio with a consistent API. The drawback is an extra dependency and a slightly different cancellation model than native asyncio, which can surprise you when mixing the two styles in one codebase.

FAQ

Does asyncio.gather cancel other tasks when one raises?
No. With the default return_exceptions=False, the first exception propagates to the awaiter, and the remaining tasks continue running. Use asyncio.TaskGroup if you want them cancelled.

How do I catch an exception from asyncio.TaskGroup?
Use except* around the async with block, for example except* ValueError as eg:. Errors are always delivered inside an ExceptionGroup, so a plain except ValueError: will not match.

Should I use gather or TaskGroup in new code?
Prefer TaskGroup on Python 3.11 and later when tasks belong together and one failure should stop the rest. Keep gather(return_exceptions=True) for independent, best-effort fan-out.

Bottom line

For new code on Python 3.11+, default to TaskGroup so a failure cannot leave orphan work behind. Reach for gather(return_exceptions=True) when you want every outcome and will inspect each one. Hold strong references to any task you spawn and do not await. On older Python, either upgrade or use AnyIO or Trio to get the same structured behavior.

Related reading