Starting concurrent work is easy. Defining when it ends is the engineering problem. A task created with asyncio.create_task() can outlive the function that launched it, fail after nobody is awaiting it, and keep resources alive after its request has disappeared.

Structured concurrency gives child tasks a lexical owner. asyncio.TaskGroup does not merely collect convenient handles. Its context is a lifetime boundary: exit waits for every child, a child failure cancels siblings, and multiple failures remain visible in an exception group.

Those semantics are powerful only when they match the product operation. Some workloads should fail fast; others should collect every independent outcome. This tutorial makes that choice explicit and examines the awkward cases: body failure, simultaneous errors, nested groups, termination, and cleanup.

Version note. Blocks were run on CPython 3.14.7. TaskGroup and ExceptionGroup arrived in Python 3.11. Python 3.13 improved simultaneous cancellation handling and cancellation-count preservation; Python 3.14 forwards keyword arguments from TaskGroup.create_task() to the event loop's task creation API. These are version-specific library behaviors, not CPython interpreter internals.

Experiment 1: the context owns child lifetime

import asyncio

async def record(value, results):
    await asyncio.sleep(0)
    results.append(value)

async def main():
    results = []
    async with asyncio.TaskGroup() as group:
        first = group.create_task(record(1, results), name='first')
        second = group.create_task(record(2, results), name='second')
        assert not first.done() or not second.done()
    assert first.done() and second.done()
    assert sorted(results) == [1, 2]

asyncio.run(main())

The async with body may continue while children run. Exiting it waits for all group tasks. Task order is not guaranteed, so the assertion checks content rather than incidental scheduling order.

The group retains strong references to its tasks and observes their results. By contrast, the event loop keeps only weak task references; unowned fire-and-forget work needs an explicit collection and done callback. Most request-scoped work should not be fire-and-forget. If a job must outlive a request, transfer it to a durable queue or a clearly supervised application service.

Python guarantee. All tasks are awaited when the task-group context exits. New tasks may be added while the group is active, including by child coroutines that receive the group.

Experiment 2: one ordinary failure cancels siblings

import asyncio

async def fail(ready):
    await ready.wait()
    raise ValueError('invalid payload')

async def wait_forever(ready, events):
    try:
        ready.set()
        await asyncio.Event().wait()
    finally:
        events.append('sibling cleaned')

async def main():
    ready = asyncio.Event()
    events = []
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(wait_forever(ready, events))
            group.create_task(fail(ready))
    except* ValueError as errors:
        assert str(errors.exceptions[0]) == 'invalid payload'
    assert events == ['sibling cleaned']

asyncio.run(main())

The first non-cancellation exception triggers cancellation of remaining children. The group waits for their cleanup, then raises an ExceptionGroup containing non-cancellation failures. This is fail-fast propagation with orderly shutdown, not instant termination.

If the task-group body is still executing when a child fails, its containing task is also cancelled so execution is drawn toward __aexit__. That internal cancellation does not normally escape as a CancelledError; the group raises the child failures after collecting them.

The guarantee assumes children do not swallow cancellation. A sibling that ignores CancelledError or blocks without awaiting can prevent group exit. Structured concurrency makes ownership explicit, but cannot forcibly interrupt uncooperative code.

Experiment 3: body failure joins child cleanup

import asyncio

async def child(started, cleaned):
    started.set()
    try:
        await asyncio.Event().wait()
    finally:
        cleaned.set()

async def main():
    started = asyncio.Event()
    cleaned = asyncio.Event()
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(child(started, cleaned))
            await started.wait()
            raise RuntimeError('body failed')
    except* RuntimeError as errors:
        assert len(errors.exceptions) == 1
    assert cleaned.is_set()

asyncio.run(main())

An exception in the body is treated like a child failure: outstanding tasks are cancelled and awaited, and the body exception participates in the eventual group. The context therefore owns cleanup even when orchestration itself fails.

KeyboardInterrupt and SystemExit receive special treatment. The group still cancels and awaits children, but re-raises the original base exception instead of wrapping it in a BaseExceptionGroup. This preserves process-control behavior.

Resource ownership should align with this lifetime. Put a connection or temporary directory outside the group when every child needs it and it must outlive them. Put child-specific resources inside each child. Lexical nesting then mirrors teardown order.

Experiment 4: simultaneous failures are not flattened away

import asyncio

async def fail_together(error, ready, release):
    ready.set()
    await release.wait()
    raise error

async def main():
    ready_a = asyncio.Event()
    ready_b = asyncio.Event()
    release = asyncio.Event()
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(fail_together(ValueError('a'), ready_a, release))
            group.create_task(fail_together(TypeError('b'), ready_b, release))
            await ready_a.wait()
            await ready_b.wait()
            release.set()
    except* Exception as group_error:
        types = {type(error) for error in group_error.exceptions}
        assert types == {ValueError, TypeError}

asyncio.run(main())

Sequential await often exposes only the first exception while later tasks become warnings or hidden results. An exception group preserves concurrent plurality. Its tree shape can also preserve nested task-group boundaries, which gives failures useful provenance.

Exception order should not be a business contract. Scheduling determines which failures become visible before sibling cancellation takes effect. This experiment uses a barrier so both tasks raise after release, but production races are less controlled. Assert failure categories and context, not arbitrary list positions.

Experiment 5: except* handles matching leaves

errors = ExceptionGroup(
    'batch',
    [ValueError('bad row'), OSError('disk'), ValueError('bad date')],
)

handled = []
try:
    raise errors
except* ValueError as matching:
    handled.extend(str(error) for error in matching.exceptions)
except* OSError as matching:
    handled.extend(str(error) for error in matching.exceptions)

assert set(handled) == {'bad row', 'bad date', 'disk'}

except* splits a group by matching leaves. Multiple clauses may run for one raised group; this differs from ordinary except, where the first matching clause handles the whole exception. Unmatched leaves are automatically recombined and propagate.

Handlers receive ephemeral subgroup objects. Do not mutate them expecting to alter the original group. Raise a new exception from a subgroup when adding domain context, or use the group's subgroup() and split() APIs for programmatic classification.

Language guarantee. ExceptionGroup, BaseExceptionGroup, and except* are Python language features specified by PEP 654. Their traceback rendering may vary by Python version and tooling.

Experiment 6: fail-fast differs from outcome collection

import asyncio

async def divide(value):
    await asyncio.sleep(0)
    return 12 / value

async def main():
    outcomes = await asyncio.gather(
        *(divide(value) for value in [3, 0, 4]),
        return_exceptions=True,
    )
    assert outcomes[0] == 4
    assert isinstance(outcomes[1], ZeroDivisionError)
    assert outcomes[2] == 3

asyncio.run(main())

This is collect-all semantics: each input is independent, and a failed row should not cancel valid rows. gather(return_exceptions=True) returns exceptions as values in input order. Callers must inspect every result; silently filtering exceptions turns data loss into success.

Without return_exceptions=True, gather propagates the first exception to its waiter but does not automatically cancel other awaitables. That differs materially from TaskGroup. Neither behavior is universally superior. A page assembled from mandatory components often wants fail-fast. A batch validator usually wants all row errors. Express that domain rule in the orchestration primitive and return type.

Experiment 7: nested groups preserve boundaries

import asyncio

async def fail(message):
    raise ValueError(message)

async def inner():
    async with asyncio.TaskGroup() as group:
        group.create_task(fail('inner'))

async def main():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(inner())
    except* ValueError as errors:
        assert isinstance(errors.exceptions[0], ExceptionGroup)
        leaf = errors.exceptions[0].exceptions[0]
        assert str(leaf) == 'inner'

asyncio.run(main())

The outer group contains the inner group's exception group rather than erasing that layer. The traceback can show which subsystem owned the failing children. Preserve this structure in logs; flatten only when a consumer explicitly needs leaf records.

Python 3.13 corrected handling when nested groups experience internal failure cancellation and an external cancellation simultaneously. Groups process their own exceptions and ensure cancellation is not lost. Code targeting 3.11 or 3.12 should consult that version's documentation and test race-sensitive shutdown behavior there.

Experiment 8: termination is an explicit failure protocol

Task groups intentionally have no terminate() method. The documented recipe injects a task that raises a private sentinel:

import asyncio

class StopGroup(Exception):
    pass

async def stop():
    raise StopGroup()

async def worker(cleaned):
    try:
        await asyncio.Event().wait()
    finally:
        cleaned.set()

async def main():
    cleaned = asyncio.Event()
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(worker(cleaned))
            await asyncio.sleep(0)
            group.create_task(stop())
    except* StopGroup:
        pass
    assert cleaned.is_set()

asyncio.run(main())

The sentinel follows ordinary failure propagation: it cancels siblings and is then selectively suppressed. Keep its type private and narrow so real errors are not mistaken for planned termination. If normal completion can be represented by a shared event that workers check, that may communicate intent more directly.

Termination still depends on cancellation responsiveness. It is a request to unwind the structure, not a kill switch.

Design the failure policy first

Before creating children, decide:

  • Are results independent, or is any missing result fatal?
  • Should one failure stop unstarted or in-progress work?
  • Which cleanup can extend group exit?
  • Does the caller need every error or one representative error?
  • Which exception types are expected outcomes rather than defects?
  • Does any work legitimately outlive this scope, and who adopts it?

Avoid wrapping every leaf in except Exception merely to return status objects. That can erase traceback structure and accidentally convert programmer errors into routine outcomes. Catch expected domain failures close enough to enrich them; let unexpected exceptions drive fail-fast cancellation.

Observability should retain task names, input identifiers, exception causes, and group hierarchy. Logging every child and then logging the entire group duplicates incidents. Prefer one owner that records the final grouped failure, while leaf code adds structured context without declaring the incident resolved.

Results need an explicit collection shape

Task groups deliberately do not return a result list. Keep handles from create_task() when each child has a required result, then read those results only after normal group exit. If exit raises, decide whether partial results remain meaningful; do not accidentally consume them while suppressing the failure that made the operation incomplete.

For keyed fan-out, map domain keys to task handles. That preserves identity without relying on scheduling or creation order. Be careful about mutable loop variables in child closures: pass each key as an argument or bind it explicitly. The group's lifetime solves task ownership, not late-binding mistakes.

When expected failures belong beside values, define an outcome type rather than returning arbitrary exception objects. A batch result might contain key, value, and a structured validation problem. Infrastructure defects should still raise and cancel the group. This separates "row was invalid" from "validator crashed," a distinction that return_exceptions=True alone cannot enforce.

Bound fan-out before creating children

A task group waits for all children but does not limit how many may exist. Creating one task per row in a million-row file can exhaust memory even if each task eventually acquires a semaphore. Use a fixed number of worker tasks consuming a bounded queue, process input in windows, or have producers acquire capacity before creating work.

The choice changes failure semantics. Queue workers need a protocol for stopping producers when one worker fails and for acknowledging buffered items. Windowed groups make each batch a failure boundary but may delay later input. Document which items can have started when one fails.

Structured concurrency should make the tree resemble operational ownership: request, subsystem, operation. It should not create a task for every function call. Ordinary sequential awaits remain clearer when no overlap is useful, and fewer child boundaries mean fewer cancellation states to reason about.

Exercises

  1. Modify Experiment 2 so sibling cleanup raises. Inspect the resulting group and decide which failure your alert should headline.
  2. Build a batch API using gather(return_exceptions=True) and return a typed outcome for every input without losing tracebacks in logs.
  3. Nest three task groups around subsystem boundaries. Name every task and inspect a rendered grouped traceback.
  4. Send external cancellation while an inner group fails. Run the test on Python 3.12 and 3.14 and document differences.
  5. Replace an unowned create_task in an application with either a task group or an explicit long-lived supervisor.

Keep this model

A task group is a lifetime and failure boundary. On normal exit it waits for children. On ordinary failure it cancels siblings, waits for cleanup, and preserves non-cancellation failures as a group. Nested groups retain structure, while except* lets callers classify matching leaves.

Use this fail-fast model when children participate in one operation. Use explicit collect-all semantics when each outcome stands alone. Most concurrency bugs blamed on scheduling are really unanswered ownership questions: who waits, who cancels, and who observes failure. Structured concurrency makes those questions impossible to omit silently.

Primary sources