Technology · PRACTICAL GUIDE
asyncio.gather raised, but sibling tasks kept running? Choose TaskGroup or cancel owned tasks
gather propagates the first exception without cancelling siblings. Use TaskGroup for fail-together work, or retain task handles, cancel, and drain them before leaving.

asyncio.gather is an aggregation primitive, not a fail-together scope. With return_exceptions=False, the first exception reaches the caller while unfinished siblings remain scheduled. By the time the exception is caught, the gather future can already be done, so gather.cancel() returns false and changes nothing.
Decide the policy before scheduling. If every operation belongs to one consistency boundary, use TaskGroup on Python 3.11 or later. If some errors are recoverable or only selected siblings should stop, create Task objects yourself, cancel the chosen tasks, await their cleanup, then re-raise or return an explicit result.
1. Prove whether work survives the first failure
Give one coroutine a short delay before raising and another a longer delay before appending a visible marker. Await with gather, catch the first error, call gather.cancel(), then keep the loop alive. If cancel returns false and the marker appears, you reproduced late cancellation.
Our CPython 3.12.14 fixture did exactly that: the aggregate was done and the slow sibling finished. In the equivalent TaskGroup case, the slow sibling recorded cancellation. Record the interpreter and event loop.
2. Choose a failure policy before choosing an API
Use fail-together semantics when siblings update one logical result, when later work is invalid after one failure, or when every task must finish or roll back as a unit. TaskGroup expresses that ownership directly.
Keep selective control when errors are expected data, independent jobs may continue, or policy depends on which task failed. Do not discard task handles; explicit ownership makes cleanup observable.
3. Use TaskGroup for fail-together work on Python 3.11+
Create every sibling through the same TaskGroup. On the first non-cancellation failure, the group requests cancellation of remaining tasks, waits for them, and raises an ExceptionGroup. Catch specific failures with except* outside the async with block.
import asyncio async def run_batch(jobs): try: async with asyncio.TaskGroup() as group: tasks = [group.create_task(job()) for job in jobs] except* RecoverableJobError as errors: log_recoverable(errors) raise return [task.result() for task in tasks]
4. For selective policies, own, cancel, drain, and re-raise
Create Task objects before gathering so the caller retains control. In an exception handler, cancel only siblings the policy says must stop. Await all owned tasks with gather(..., return_exceptions=True) so their finally blocks run and no exception is left unretrieved.
tasks = [asyncio.create_task(job()) for job in jobs] try: return await asyncio.gather(*tasks) except BaseException: for task in tasks: if not task.done(): task.cancel() await asyncio.gather(*tasks, return_exceptions=True) raise
5. Keep cancellation cooperative and observable
A task receives CancelledError at an await point. Put resource cleanup in try/finally and normally re-raise CancelledError. Blocking code, swallowed cancellation, or work delegated to a thread may outlive the coroutine cancellation request.
Assert four things: gather alone lets a sibling continue; TaskGroup cancels and waits; the manual pattern drains every owned task; and external cancellation of the parent still propagates after cleanup. Inspect side-effect logs, not only task states.
