asyncio.TaskGroup is not a Python 3.13 novelty. It arrived in Python 3.11 and is a mature option for services on 3.12 or 3.13. Its central promise is about ownership: before the task-group context exits, every child created in that group has finished. When one child fails with an ordinary exception, its siblings are cancelled and the failures are reported to the parent, potentially as an exception group.

That lifecycle guarantee is more important than the convenience of a shorter API. It prevents a request from spawning work that quietly survives after the request has already failed or returned.

Put creation and convergence in one scope

Suppose a dashboard needs a profile, inventory, and access decision concurrently. With untracked calls to create_task(), it is easy to lose references or forget a cleanup path. A task group keeps creation beside the boundary that waits for all work:

import asyncio

async def load_dashboard(user_id: str) -> dict[str, object]:
    async with asyncio.TaskGroup() as group:
        profile = group.create_task(fetch_profile(user_id))
        stock = group.create_task(fetch_stock(user_id))
        access = group.create_task(fetch_access(user_id))

    return {
        "profile": profile.result(),
        "stock": stock.result(),
        "access": access.result(),
    }

Results are read only after the context exits normally, when all three tasks are complete. If inventory fails, the profile and access tasks receive cancellation. The caller does not observe a vague state where half the work is complete while the rest continues in the background.

This is not a transaction. An email already sent or a write accepted by another service is not automatically reversed. Side effects inside concurrent children still require idempotency keys, compensating operations, or a design that computes in parallel and commits in a controlled second phase.

Let cancellation propagate

Cancellation enters a coroutine as asyncio.CancelledError. Cleanup belongs in try/finally, but after cleanup the cancellation should normally continue. Catching BaseException, suppressing cancellation, and returning an ordinary value can interfere with the protocols used by task groups and timeout contexts.

async def fetch_profile(user_id: str) -> dict:
    connection = await open_connection()
    try:
        return await connection.get(f"/users/{user_id}")
    finally:
        await connection.close()

This function does not need to catch cancellation. The finally block runs for success, failure, and cancellation. If a client library requires an explicit cancellation handler for logging, log and then raise again. Also avoid passing a live task group into a global object whose lifetime is unclear; code should not be able to attach unrelated work long after the owning operation has conceptually ended.

Preserve the structure of failures

Several children can fail almost simultaneously before cancellation reaches all of them. On exit, TaskGroup combines non-cancellation failures. The caller can use except* to handle recoverable categories without pretending there was only one error:

try:
    result = await load_dashboard("u-42")
except* TimeoutError as failures:
    record_timeouts(failures.exceptions)
except* PermissionError:
    raise DashboardUnavailable("access denied")

Make the recovery decision at the layer that has enough context. Low-level functions should preserve specific exceptions and useful tracebacks; a service boundary can decide whether to retry, degrade, or translate the problem for an API response. Flattening everything into a string early discards the exception group’s structure and makes diagnosis harder.

Give timeouts a meaningful scope

asyncio.timeout() can surround the whole task group to express one budget for the combined operation. It can also surround a single optional dependency. A whole-operation timeout fits an incoming request deadline; a local timeout fits data that can be omitted or replaced by a fallback.

If both exist, leave room in the outer budget for cancellation and resource cleanup. Identical nested deadlines can interrupt logging and connection closing just when those actions are most valuable. Retries also need one owner. Use bounded attempts, backoff, and jitter for idempotent reads. Before retrying a write, establish a request identity and server-side deduplication. A task group converges tasks; it cannot decide whether repeating an effect is safe.

Test outcomes instead of scheduler luck

Concurrency tests should not depend on real sleeps or a particular print order. Use events and controlled fakes: hold one child at a checkpoint, make a sibling fail, then assert that the waiting child was cancelled and released its resource. Cover parent cancellation, two failures becoming an ExceptionGroup, the overall timeout, and the all-success path.

Tests should inspect final state and ownership. A test that passes because one task happened to run first proves very little and will become flaky under a different machine or event-loop load.

When not to use a group

Long-lived background consumers and process-wide supervisors do not naturally fit inside a request-scoped task group. They still need explicit ownership, shutdown, monitoring, and restart policy, but their scope may be the application lifecycle rather than one function. Conversely, if operations must happen in a strict order or later work depends on an earlier result, sequential await is clearer than creating artificial concurrency.

TaskGroup is valuable because the parent becomes responsible for every child. Keep creation inside a visible scope, preserve cancellation, handle grouped failures by type, and design external effects for safe retry or compensation. Structured concurrency does not eliminate failure; it gives failure a predictable owner and a definite place to finish.