Creating tasks is easy; ensuring every task finishes, is cancelled, and has its exceptions observed is harder. asyncio.TaskGroup, available since Python 3.11, gives related tasks a visible lifecycle.
Review async and await before adding concurrency to production code.
import asyncio
async def fetch(name: str, delay: float) -> str:
await asyncio.sleep(delay)
return f"{name}: ok"
async def main():
async with asyncio.TaskGroup() as group:
users = group.create_task(fetch("users", 0.2))
orders = group.create_task(fetch("orders", 0.1))
print(users.result(), orders.result())
asyncio.run(main())
The context exits only after both tasks finish. Read results afterward.
If one task raises a relevant exception, the group cancels the others and propagates failures in an ExceptionGroup:
try:
async with asyncio.TaskGroup() as group:
group.create_task(import_users())
group.create_task(import_orders())
except* ValueError as errors:
for error in errors.exceptions:
log(error)
Do not swallow CancelledError. The official TaskGroup documentation details cancellation behavior.
asyncio.gather() remains useful, but TaskGroup provides stronger guarantees for tasks that form one unit. Neither speeds up CPU-bound work; see threads and multiprocessing for that distinction.
Use timeouts deliberately, release resources in finally, limit calls to external services, and name tasks when it improves observability. Structured concurrency reduces orphan tasks and makes concurrent failures predictable to callers.
Keep task references when results matter
TaskGroup.create_task() returns a Task. Store it when a caller must associate each result with its input:
async def fetch_all(ids: list[int]) -> dict[int, dict]:
tasks: dict[int, asyncio.Task[dict]] = {}
async with asyncio.TaskGroup() as group:
for customer_id in ids:
tasks[customer_id] = group.create_task(
fetch_customer(customer_id),
name=f"customer-{customer_id}",
)
return {
customer_id: task.result()
for customer_id, task in tasks.items()
}
Read result() after a normal context exit. Inside the block, a task may still be running. If one query fails, execution does not reach the return statement: the group shuts down its remaining work and propagates the failure. Task names do not alter scheduling, but they make logs and debugging output easier to interpret.
A coroutine may add work to the same group while it is active, which supports dynamically discovered trees of tasks. Once the last task completes and the context has finished, the group no longer accepts additions.
Cancellation is cooperative
Asyncio cancellation does not forcibly terminate code. It raises CancelledError at a suspension point such as await. A coroutine should release resources and let cancellation continue:
async def consume():
connection = await open_connection()
try:
while True:
item = await receive(connection)
await process(item)
finally:
await connection.close()
Avoid catching BaseException. If you must catch CancelledError to record state or perform cleanup, finish that work and raise it again. Structured concurrency components, including task groups and asyncio.timeout(), rely on cancellation internally. Swallowing it can delay shutdown or break the guarantees around the block.
KeyboardInterrupt and SystemExit are special cases. The group still cancels and waits for its children, but then re-raises the original exception instead of wrapping it in an exception group. Ordinary failures can be grouped when multiple tasks fail during shutdown.
Handle exception groups selectively
The except* syntax selects matching parts of an ExceptionGroup:
try:
async with asyncio.TaskGroup() as group:
for path in paths:
group.create_task(import_file(path))
except* InvalidFile as errors:
for error in errors.exceptions:
record_rejection(error)
except* TimeoutError as errors:
for error in errors.exceptions:
record_timeout(error)
Handling one type does not silently discard unrelated failures. Unmatched exceptions continue upward. That property helps prevent a programming defect from looking like success. Often the best approach is to attach useful context close to each operation, then let the group reach a layer that can decide whether to retry, return an error, or stop the process.
Do not make business rules depend on exception order. Completion order varies with the network, operating system, and workload. If individual items are allowed to fail independently, catch the expected exception inside each task and return an explicit success-or-failure value. The group will then regard that task as successfully completed and will not cancel its siblings.
Put a deadline around the whole operation
An outer timeout gives the complete operation one time budget:
async def load_dashboard() -> tuple[dict, list]:
async with asyncio.timeout(2.0):
async with asyncio.TaskGroup() as group:
summary = group.create_task(fetch_summary())
alerts = group.create_task(fetch_alerts())
return summary.result(), alerts.result()
When the deadline expires, the timeout context cancels the current task. The task group then shuts down its children before TimeoutError reaches the caller. A separate timeout inside every child means something different: one slow dependency fails, and the escaped exception normally cancels its siblings. Choose the boundary according to the service contract.
TaskGroup is not a concurrency limiter. If ten thousand tasks immediately wait for a semaphore, all ten thousand task objects and their arguments still occupy memory. For large streams, use a fixed number of workers with asyncio.Queue, process pages, or otherwise apply backpressure.
TaskGroup versus gather
asyncio.gather() is concise for a fixed collection of awaitables when the caller wants results in input order. With return_exceptions=True, it can represent failures as values, which is appropriate for genuinely independent operations. Every position must then be inspected; accidentally treating an exception value as a result creates a false success.
TaskGroup does not return a list and has no direct return_exceptions=True mode. Instead, it ties creation, waiting, and cancellation to one lexical scope. That is a strong default when children form a unit, such as the mandatory pieces of one response.
Concurrency is not automatically beneficial. If operation B requires A's result, direct sequential awaits are clearer. Concurrent calls can also exhaust database pools, API quotas, file descriptors, and memory. Set explicit limits and measure both latency and capacity.
Test failure and cleanup
A valuable test forces one child to fail and verifies that a sibling runs its cleanup. Coordinate the scenario with asyncio.Event instead of relying only on arbitrary sleeps. Test the public contract as well: which exception reaches the caller, whether partial side effects are allowed, and whether retrying is safe.
TaskGroup belongs to the standard library starting in Python 3.11. If a package supports older interpreters, document that constraint and select a compatibility approach intentionally. For current applications, a structured block is usually easier to review than scattered calls to create_task() whose ownership is unclear.
A production review checklist
Before shipping a group, identify which children are mandatory and whether any expected failure should become a value instead of an exception. Place the timeout around the actual service-level budget, and confirm that each coroutine releases connections, locks, and temporary files when cancelled.
Check capacity as well as correctness. Count the maximum simultaneous calls produced by nested groups and compare it with connection pools and provider quotas. If inputs are unbounded, introduce a queue or batching strategy before creating tasks.
Finally, make ownership visible. A task created inside the group should finish there; background work that must outlive a request needs a separate, supervised lifecycle. Log stable operation identifiers rather than relying only on task names. In tests, exercise normal completion, one child failure, multiple failures during cleanup, caller cancellation, and deadline expiration. These cases reveal most mistakes that a successful demonstration cannot show.
Reviewing those cases before deployment also gives operators clearer expectations when a dependency becomes slow or unavailable.