Python asyncio — gather() Cancels Sibling Tasks on First Failure
Understand why asyncio.gather aborts remaining awaitables when one raises, keep sibling work alive with return_exceptions, and handle ExceptionGroup-style fan-out failures.
Symptoms
- One failed sub-task in a gather() fan-out leaves all sibling results missing, even ones that were nearly complete
- CancelledError appears in sibling task logs even though their own logic never failed
- A batch of 10 jobs reports a single error and 9 cancelled with zero completed results delivered
Possible causes
- Default gather() semantics propagate the first exception immediately and cancel the not-yet-finished awaitables
- Siblings were independent by design, so losing them turns a partial failure into a total one
- Nested gathers multiply the effect: one leaf failure cancels a whole subtree of otherwise healthy work
- Code reads results = await gather(...) assuming all-or-nothing is impossible, then discards the partial data it needed
Diagnosis steps
Confirm the cancellation semantics in a minimal repro
python -c "import asyncio async def ok(): await asyncio.sleep(0.1); return 'ok' async def boom(): await asyncio.sleep(0.01); raise ValueError('x') async def main():
try:
await asyncio.gather(ok(), boom())
except ValueError:
pass
asyncio.run(main())" 2>&1 | head -5Interpretation
The ok() task never returns its value: the ValueError propagates first and the sibling is cancelled — the default gather contract made visible.
Audit gather call sites for independence
grep -Rn "asyncio.gather\|asyncio.gather(" src/ | head -20Interpretation
Every fan-out where tasks do not depend on each other is a candidate for return_exceptions=True.
Inspect failure fan-in logs
grep -R "CancelledError" logs/ | tail -20Interpretation
CancelledError entries synchronized with the primary failure timestamp reveal the cancellation cascade rather than independent bugs.
Root cause
asyncio.gather by default raises the first exception it receives and cancels the remaining awaitables. For independent tasks this converts a partial failure into a total one: work that was already nearly done is actively cancelled and its results are lost, while the caller receives only the single exception.
Fix
Pass return_exceptions=True when siblings are independent, then partition results into values and exceptions. When a group genuinely needs all-or-nothing, use asyncio.TaskGroup for structured cancellation with explicit cleanup, and keep per-task timeouts so one slow sibling cannot hold the group.
Risk note
return_exceptions=True also captures CancelledError, which can hide shutdown signals. Distinguish cancellation from business failure when partitioning results.
Verify the fix
Inject a failure into one fan-out branch and assert siblings still deliver results, the exception is reported per task, and no CancelledError appears in sibling logs.
Symptom walkthrough
The minimal reproduction is five lines and settles the semantics question instantly:
import asyncio
async def ok():
await asyncio.sleep(0.1)
return 'ok'
async def boom():
await asyncio.sleep(0.01)
raise ValueError('x')
async def main():
result = await asyncio.gather(ok(), boom())
asyncio.run(main())
boom() raises first, gather propagates the ValueError, and ok() — 90% of the way to done — is cancelled. In production the fingerprint is sibling logs full of CancelledError at exactly the primary failure timestamp, plus a caller that received one error and zero partial results.
Fix in practice
For independent tasks, opt into full results and partition them:
results = await asyncio.gather(
*coros,
return_exceptions=True,
)
values, failures = [], []
for item in results:
if isinstance(item, BaseException):
if isinstance(item, asyncio.CancelledError):
raise item # never swallow shutdown signals
failures.append(item)
else:
values.append(item)
The CancelledError re-raise matters: return_exceptions=True captures cancellation like any exception, and swallowing it would break cooperative shutdown. When the group truly is all-or-nothing, prefer structure over accident:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(coro()) for coro in coros]
# raises ExceptionGroup with every failure, cancels cleanly
TaskGroup makes the all-or-nothing choice explicit and reports every failure instead of whichever arrived first.
Verification
Build a fan-out harness: five tasks, one configured to fail at a chosen delay. With the fix, the run must deliver four values plus one exception object, and grep of sibling logs must show zero CancelledError entries. Vary the failing delay across runs to confirm independence from failure order. For the TaskGroup variant, assert the raised ExceptionGroup contains exactly the injected failures and that all non-failing tasks completed before the group exited — the explicit complement of the gather behavior you just replaced.