asyncio.gather() and manually-managed create_task() calls have a real, easy-to-hit gap: an exception in one task doesn't automatically cancel its siblings, and a forgotten reference to a task can silently disappear. asyncio.TaskGroup, added in 3.11, closes that gap with structured concurrency ā a with block that guarantees every task inside it is properly awaited and cleaned up.
1gather()'s Real Gap: Orphaned Sibling Tasks on Failure
asyncio.gather(risky(), slow_but_fine()) runs both coroutines concurrently, and when risky() raises after one second, that exception does propagate out of gather() to the caller ā but slow_but_fine(), already running as an independent task internally, is not automatically cancelled. It keeps running in the background for its full five seconds, its print statement still executes, and depending on the surrounding code, that orphaned task might complete after the function that started it has already moved on, its result silently discarded, or it might interact with resources in ways nothing is now tracking or expecting.
This isn't a rare edge case ā it's gather()'s documented default behavior, and it's precisely the kind of gap that's easy to miss in code review, since the *happy path* (nothing raises) looks completely correct; the bug only manifests specifically when one of several concurrent operations fails, which might be an infrequent, hard-to-reproduce condition in production traffic.
The deeper problem this points at is a lack of structured concurrency: nothing guarantees that every task started under a given gather() call is fully accounted for ā finished, cancelled, or otherwise resolved ā by the time that call returns or raises. A task can, in principle, keep running independently of the code that appeared to be 'waiting' for it.
import asyncio
async def risky():
await asyncio.sleep(1)
raise ValueError("failed!")
async def slow_but_fine():
await asyncio.sleep(5)
print("slow_but_fine finished") # this STILL prints, wastefully, after the error
async def main():
await asyncio.gather(risky(), slow_but_fine())
asyncio.run(main()) # raises ValueError after ~1s, but slow_but_fine() keeps running in the backgroundslow_but_fine() keeps running in the background, unmanaged
2TaskGroup: Guaranteed Cleanup, Structured Concurrency
asyncio.TaskGroup, added in Python 3.11 specifically to close this gap, implements structured concurrency: every task created via tg.create_task(...) inside the async with asyncio.TaskGroup() as tg: block is tracked by the group, and the block does not exit ā control does not proceed past it ā until every single task inside it has either completed successfully or been cancelled. There is no way for a task started inside a TaskGroup to silently outlive the block that created it.
The cancellation behavior is the direct fix for the gather() gap: the moment *any* task inside the group raises an unhandled exception, TaskGroup automatically cancels every other still-running task in that same group, rather than letting them continue independently. Once all tasks have actually finished responding to that cancellation (a cancelled task still gets a chance to run its own cleanup code, like a finally block), the TaskGroup raises an ExceptionGroup ā a special exception type (also new in 3.11, via PEP 654) capable of representing one or more underlying exceptions from potentially multiple tasks that failed simultaneously.
This is a meaningfully stronger correctness guarantee than manually tracking a list of tasks from create_task() calls and remembering to await asyncio.gather(*tasks) or manually cancel the rest on failure ā TaskGroup makes 'every task is accounted for, and a sibling failure doesn't leave others running unmanaged' the *default*, structurally enforced behavior, rather than something you have to remember to implement correctly yourself every time.
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(risky())
tg.create_task(slow_but_fine())
# If risky() raises, slow_but_fine() is CANCELLED immediately -- no wasted work
asyncio.run(main()) # raises an ExceptionGroup, slow_but_fine() never gets to finishslow_but_fine() is cancelled immediately ā no orphaned work
3ExceptionGroup: Handling Multiple Simultaneous Failures
A genuine complication TaskGroup has to handle that gather()'s simpler model sidesteps: what if *two* tasks in the group both raise exceptions before either can be cancelled? gather() (in its default configuration) simply propagates whichever exception it encounters first, silently losing information about any others. TaskGroup instead collects every exception raised by every task in the group and raises them together as a single ExceptionGroup, which can be inspected and handled with the except* syntax (also introduced alongside ExceptionGroup in 3.11) to match against specific exception types within the group.
This matters in real systems where a batch of concurrent operations ā say, five parallel API calls ā might fail for genuinely different reasons simultaneously (one times out, another gets a 500 error), and losing all but the first failure's details makes debugging a production incident meaningfully harder. ExceptionGroup preserves the full picture: every failure, from every task, available for logging or targeted handling.
The practical upshot for professional asyncio code: TaskGroup (and its except*/ExceptionGroup companions) is now the recommended default over gather() for running multiple coroutines concurrently and needing a genuine correctness guarantee that failures are handled cleanly ā reach for plain gather() mainly for simple, best-effort cases where an orphaned background task on failure is a known, acceptable trade-off, and be explicit about that trade-off when you make it.
async def main():
async with asyncio.TaskGroup() as tg:
for i in range(5):
tg.create_task(fetch(f"item-{i}"))
# execution only reaches here once ALL 5 tasks have completed
print("all tasks are done -- guaranteed")Preserves every task's failure, not just the first one encountered
4Step-by-Step Breakdown
gather() has a genuine correctness gap around exception handling that catches experienced asyncio users too. TaskGroup, from 3.11, was built specifically to close it.
gather()'s gap: if one coroutine raises, the others keep running in the background ā they aren't automatically cancelled, which can waste work or leave things in a weird state.
Checkpoint: With plain asyncio.gather(risky(), slow_but_fine()), if risky() raises after 1 second, what happens to slow_but_fine()?
- āIt keeps running in the background for its full 5 seconds, even though the exception already propagated
- āIt is automatically cancelled the instant risky() raises
asyncio.TaskGroup (3.11+) fixes this: if ANY task raises, every other task in the group is automatically cancelled.
Checkpoint: What does asyncio.TaskGroup do differently from gather() when one task inside it raises?
- āIt automatically cancels every other task in the group
- āIt silently ignores the exception and continues
The with block itself won't exit until EVERY task inside it has finished (or been cancelled) -- no task can be silently forgotten or leaked.
With individual coroutines, concurrent scheduling, and now structured task groups covered, concurrent.futures rounds out this section with a unified API spanning both threads and processes.
Catch a Real TaskGroup Exception. Finish main(): TaskGroup wraps a task's exception in an ExceptionGroup.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Default to asyncio.TaskGroup over gather() for new code on 3.11+
TaskGroup's structured concurrency guarantees no task is silently orphaned on a sibling's failure ā a real correctness improvement over gather()'s default behavior, at very little extra syntax cost.
Use except* to handle specific exception types within an ExceptionGroup
This lets you respond differently to different failure types from a batch of concurrent tasks (e.g. retry on TimeoutError, log and re-raise on ValueError) instead of only ever seeing the first exception encountered.
Frequent Bugs
Using asyncio.gather() for several concurrent operations where one failing should logically stop the others, without realizing the others keep running independently in the background on failure.
Switch to asyncio.TaskGroup, which automatically cancels sibling tasks the moment any task in the group raises, closing exactly this gap.
Real-World Examples
Fetching From Multiple Required Data Sources With All-or-Nothing Semantics
A report-generation job needs data from three required sources; if any one fails, the job should stop immediately and not waste time/resources finishing the other two fetches for a report that will fail anyway.
import asyncio
async def generate_report():
async with asyncio.TaskGroup() as tg:
sales_task = tg.create_task(fetch_sales_data())
inventory_task = tg.create_task(fetch_inventory_data())
forecast_task = tg.create_task(fetch_forecast_data())
# Reaches here only if ALL succeeded; otherwise an ExceptionGroup was raised
# and any still-running fetch was already cancelled automatically
return build_report(sales_task.result(), inventory_task.result(), forecast_task.result())