TaskGroup gives related child tasks one failure boundary. That is different from merely collecting awaitables into a result list.
Core answer
Use TaskGroup in Python 3.11+ when subtasks belong to one parent operation and should finish or fail together.
# [CURRENT - 3.11-3.14] Requires Python 3.11+ [PEP 654]import asynciofrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class ProfilePage: user: str orders: tuple[str, ...]async def user(user_id: str) -> str: await asyncio.sleep(0.01) return user_idasync def orders(user_id: str) -> tuple[str, ...]: await asyncio.sleep(0.01) return (f"{user_id}:ORD-1",)async def page(user_id: str) -> ProfilePage: async with asyncio.TaskGroup() as group: user_task = group.create_task(user(user_id)) order_task = group.create_task(orders(user_id)) return ProfilePage(user_task.result(), order_task.result())print(asyncio.run(page("U-7")))Why this design exists
Structured concurrency makes child-task ownership visible. TaskGroup uses cancellation and exception grouping so failures do not silently leave sibling work detached. PEP 654 supplies the ExceptionGroup and except* machinery that makes multiple child failures representable.
Mechanics and CPython internals
The task group is an asynchronous context manager. On non-cancellation child failure, it cancels siblings, waits for shutdown, and raises grouped exceptions after the boundary. Cancellation is ordinary async control flow, so cleanup belongs in try/finally.
# [CURRENT - 3.11-3.14] Requires Python 3.11+ [PEP 654]import asynciofrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class Dependency: name: str fail: boolasync def call(dep: Dependency) -> str: await asyncio.sleep(0.01) if dep.fail: raise OSError(dep.name) return dep.nameasync def main() -> None: try: async with asyncio.TaskGroup() as group: group.create_task(call(Dependency("profile", True))) group.create_task(call(Dependency("orders", False))) except* OSError as failures: print(len(failures.exceptions))asyncio.run(main())Complexity and tradeoffs
Task creation and cancellation have overhead, but the key tradeoff is failure semantics. gather is useful for aggregation; TaskGroup is useful for ownership. A task group does not bound fan-out by itself, so pair it with semaphores or queues when capacity matters.
Idiomatic patterns and refactoring
Refactor fire-and-forget sibling tasks into one context boundary.
# [CURRENT - 3.11-3.14] Requires Python 3.11+ [PEP 654]import asynciofrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class Audit: order_id: strasync def write_audit(audit: Audit) -> None: await asyncio.sleep(0) print(audit.order_id)async def detached(audit: Audit) -> None: asyncio.create_task(write_audit(audit)) await asyncio.sleep(0)async def owned(audit: Audit) -> None: async with asyncio.TaskGroup() as group: group.create_task(write_audit(audit))asyncio.run(owned(Audit("ORD-7")))Common mistakes and edge cases
Do not swallow CancelledError without a reason and cleanup plan. Do not expect TaskGroup to preserve input result order as an aggregation API. Do not create unbounded child tasks and call it structured enough.
When to use / When NOT to use
Use TaskGroup for one operation with related child tasks and explicit failure ownership.
Do not use it when a sequential await is clearer or when aggregation semantics from gather are the actual requirement.