Python 3.11 TaskGroups and Structured Concurrency

Python 3.11 introduced asyncio.TaskGroup, an asynchronous context manager that establishes structured concurrency within the standard library. This article covers what TaskGroup is, the problems it solves compared to older APIs like asyncio.gather(), how it manages task lifecycles, and how it handles errors using Python 3.11's ExceptionGroup.

What is Structured Concurrency?

Structured concurrency is a programming paradigm where concurrent operations have a well-defined entry point, exit point, and lifespan tied to a specific execution scope. In traditional asynchronous programming, concurrent tasks can easily become "orphaned"—continuing to run in the background, consuming resources, and producing silent failures if their caller exits early due to an error.

Under structured concurrency, a parent execution block cannot finish until all of its spawned child tasks have finished. If any child task fails, the remaining sibling tasks are cleanly cancelled and cleaned up before control returns to the caller.

The Problem with asyncio.gather()

Prior to Python 3.11, the primary tool for managing concurrent tasks was asyncio.gather(). While functional, asyncio.gather() suffers from several pitfalls:

How asyncio.TaskGroup Works

asyncio.TaskGroup enforces structured concurrency using an asynchronous context manager (async with). Tasks are spawned inside the context block using tg.create_task(). When the context manager exits, it automatically awaits all tasks spawned within it.

import asyncio

async def fetch_data(id: int, delay: int):
    await asyncio.sleep(delay)
    return f"Data {id}"

async def main():
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(fetch_data(1, 1))
        task2 = tg.create_task(fetch_data(2, 2))

    # Both tasks are guaranteed to be complete here
    print(task1.result())
    print(task2.result())

asyncio.run(main())

In this model, the context manager block will not exit until task1 and task2 have completed execution.

Error Handling and ExceptionGroup

The core advantage of TaskGroup is deterministic error handling:

  1. Automatic Cancellation: If a task raises an unhandled exception, TaskGroup immediately cancels all other active tasks within its scope.
  2. Graceful Teardown: It waits for all cancelled tasks to finish their cancellation routines before exiting the context block.
  3. Aggregated Errors: Instead of discarding errors, TaskGroup collects all exceptions from failed tasks into an ExceptionGroup.

Python 3.11 also added the except* syntax specifically to catch and handle sub-exceptions within an ExceptionGroup:

import asyncio

async def faulty_task():
    await asyncio.sleep(0.5)
    raise ValueError("Something went wrong")

async def slow_task():
    try:
        await asyncio.sleep(2)
    except asyncio.CancelledError:
        print("Slow task was cancelled safely")
        raise

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(faulty_task())
            tg.create_task(slow_task())
    except* ValueError as eg:
        print(f"Handled validation error: {eg.exceptions}")

asyncio.run(main())

When faulty_task raises a ValueError, TaskGroup cancels slow_task, waits for it to handle the cancellation, and packages the ValueError into an ExceptionGroup caught by except* ValueError.

Key Takeaways

asyncio.TaskGroup represents the modern, recommended approach for managing concurrent tasks in Python: