LESSON 119 · DEEPER PYTHON30 min

Manage Async Tasks with TaskGroup and Cancellation

Use asyncio.TaskGroup to wait for related tasks, retrieve results in input order, and understand sibling cancellation after failure.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Manage Async Tasks with TaskGroup and Cancellation

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    The parent function created a task, printed its message, and returned. Does the caller know whether the child finished? No. Depending on scheduling, a marker from the child might appear in a different small example, but the function has not defined a reliable completion point. We should not build a program around whatever happens in one accidental run. In the previous asyncio lesson, we used `gather` to wait for independent coroutines and collect results. Today we need a different guarantee: a group of related tasks should have a visible lifetime, and a failure in one should coordinate the shutdown of its siblings.

  2. 00:40

    Python's `asyncio.TaskGroup` provides that structure. We will run small coroutines with `asyncio.sleep`, which yields control to the event loop, so this is real asynchronous scheduling. There is no external API, login, or network dependency in the demonstration. The interesting behavior is when we leave the group, read results, and then make one child fail. Do not treat this as a claim that `create_task` is bad. It is a useful primitive when a task's ownership and awaiting are carefully managed. The broken helper fails because it gives no owner to the child and no result contract to its caller.

  3. 01:20

    Watch the output order, but more importantly watch where the code guarantees all related work has finished. That guarantee is what makes the later cleanup example understandable. The `async with` block starts a group with a clear entrance and exit. `group.create_task` puts each coroutine under that group's management. A successful exit waits for the children to complete, so reading `first.result()` and `second.result()` afterward is safe. If we tried to read a result immediately after `create_task`, before yielding or waiting, the task might not be done and `result()` could raise `InvalidStateError`. The point is not to memorize a timing trick; the context boundary is the correct

  4. 02:02

    place to know that successful work has completed. The group also gives us an ownership rule. These two jobs are related to one `main` operation. If that operation fails, its children should not wander on as forgotten background work. This is why the feature is called structured concurrency: task lifetimes follow the structure of the program. It does not make synchronous code magically parallel, and it does not guarantee a performance gain for CPU-heavy Python calculations. It is most useful when coroutines spend time awaiting independent operations. The zero-delay sleeps in our example only demonstrate scheduling; a production

  5. 02:45

    program might await I/O and need timeouts or resource management. Notice that the group does not automatically assemble a result list. It returns Task objects, and we choose when and in what order to read their results. We will make that result-order contract explicit next. The second task has no programmed delay, while the first waits briefly. The output list still says one then two because the Task objects are saved in that order and we read them in that order after the group exits. Completion order and result order are separate ideas. If a feature shows search results or processed records corresponding to an

  6. 03:25

    input list, preserving the input order may be important even when each item finishes at a different time. The lesson's practical task applies exactly this pattern to labels. We number every label with `enumerate(values, 1)`, create a task for each label, leave the TaskGroup, and read results from the saved list. Duplicates should stay duplicated, and an empty input should return an empty list. None of these requirements is solved simply by launching more tasks. They are ordinary data contracts that the asynchronous code must preserve. Also notice where `result()` appears. If you moved it into the `async with` body immediately after task creation, you would

  7. 04:08

    race against scheduling and could observe unfinished tasks. We do not need to guess when the event loop will run each child. We use the group's successful exit as the synchronization point. That makes the code easier to test in the browser console and easier for another programmer to review. It also prepares us to understand why a failed exit behaves differently from a successful one. This is the behavior that makes a TaskGroup more than a convenience wrapper for collecting results. One child raises ValueError. The group cancels unfinished siblings, waits for their shutdown, and reports failures as an ExceptionGroup.

  8. 04:49

    The slow child is interrupted while awaiting sleep, but its `finally` block runs and prints a cleanup marker. Only after that do we see the outer failure report. In a real worker, a finally block might close an open file, release a lock, or return a borrowed resource. We catch the ValueError group outside the context solely to show the order and keep the example running. Do not write a broad `except asyncio.CancelledError: return None` inside the worker just to silence cancellation. TaskGroup uses cancellation internally to coordinate shutdown, and swallowing it without careful reasoning can break the intended lifecycle.

  9. 05:31

    Keep cleanup in finally and normally let cancellation propagate. The `except*` syntax is for matching exceptions inside an ExceptionGroup; it is not a replacement for every `except` in ordinary synchronous code. Python 3.11 added TaskGroup, and the site's Python 3.13 runtime supports it. This example deliberately uses one quick failure so the one-second sleep is cancelled early. You can change the sleeps and see the cleanup still run, but do not rely on exact print timing as an application protocol. Rely on the documented group semantics and test the resources that matter.

  10. 06:13

    The first assertion checks the prepared pair. The second makes order and duplicates visible; the repeated B values receive different position numbers. The third checks that zero work returns an empty list rather than raising or returning None. These are small tests, but each covers a different contract. The browser lesson starts with the label coroutine already written and asks you to implement the collector. Use `async with asyncio.TaskGroup() as group`, save the created tasks in input order, and return their results only after leaving the context. Run your code, select Check task, and complete the quiz.

  11. 06:53

    The failure demonstration from the previous section is separate from the successful practice task, but it explains why TaskGroup's structured lifetime matters. If your program launches unrelated long- running services, do not casually place them all in one TaskGroup and assume their failures should cancel one another. Define which tasks belong to one operation. If a worker can hold a resource, use `try/finally` for cleanup. If output must reflect input order, save the tasks in input order rather than relying on completion order. Those three questions—ownership, cleanup, and result order—are more useful than a mechanical recipe for creating a task.

  12. 07:35

    The result is code whose behavior remains clear when the easy case works and when one child fails.

Understand the concept

The asyncio basics lesson used gather for independent awaitables. A TaskGroup is a structured way to manage a related set of tasks. Inside async with asyncio.TaskGroup() as group, call group.create_task(coroutine) for each job. Leaving the context waits for its tasks, so the function does not return while a child is still running. This lifecycle is the main difference to notice; creating a task without keeping and awaiting it can make control flow difficult to reason about.

Task completion order is not input order. Save returned Task objects in a list and, after the async with block exits successfully, read each task.result() in list order. A zero-delay sleep is enough to yield control and make the example genuinely asynchronous without depending on network access. An empty input should return an empty list. The exercise numbers uppercase labels to make each result visible and testable.

When one TaskGroup child raises a non-cancellation exception, the group cancels unfinished siblings and eventually raises an ExceptionGroup. That behavior differs from casually spawning tasks and forgetting some of them. A worker can use try/finally to release resources when cancellation happens. Do not swallow asyncio.CancelledError in a worker merely to make an error disappear; structured concurrency uses cancellation to coordinate shutdown. It is normal for cleanup code to run before the outer exception is reported.

The task here focuses on the successful result path and the guide separately demonstrates failure cleanup. Test the output order, duplicates, and empty input. Then run the failure example and watch a sibling's finally block print before the ExceptionGroup is caught. TaskGroup was added in Python 3.11; this site's Python 3.13 runtime supports it. Real I/O may need timeouts, retries, and resource-specific cleanup beyond this classroom example.

  • TaskGroup waits for children on context exit
  • Save tasks to read results in input order
  • A failed child cancels unfinished siblings
  • try/finally supports cancellation cleanup

See it step by step

Read the code, predict the output, then compare it with the result.

01. Wait for a group of tasks

PYTHON
import asyncio
async def job(name):
    await asyncio.sleep(0)
    return name.upper()
async def main():
    async with asyncio.TaskGroup() as group:
        first = group.create_task(job('ada'))
        second = group.create_task(job('grace'))
    print(first.result(), second.result())
asyncio.run(main())
EXPECTED OUTPUT
ADA GRACE

The context finishes both tasks before their results are read.

02. Keep results in submission order

PYTHON
import asyncio
async def job(number, delay):
    await asyncio.sleep(delay)
    return number
async def main():
    async with asyncio.TaskGroup() as group:
        tasks = [group.create_task(job(1, 0.01)), group.create_task(job(2, 0))]
    print([task.result() for task in tasks])
asyncio.run(main())
EXPECTED OUTPUT
[1, 2]

The second task may finish sooner, but the saved task list determines output order.

03. Clean up a cancelled sibling

PYTHON
import asyncio
async def slow():
    try:
        await asyncio.sleep(1)
    finally:
        print('slow cleaned up')
async def fail():
    await asyncio.sleep(0)
    raise ValueError('broken')
async def main():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(slow())
            group.create_task(fail())
    except* ValueError:
        print('group reported failure')
asyncio.run(main())
EXPECTED OUTPUT
slow cleaned up
group reported failure

The failing child causes unfinished siblings to be cancelled; finally runs before the exception group is handled.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Make the task lifetime visible

The earlier asyncio lesson scheduled independent work with gather. A TaskGroup also schedules children, but its async context defines where that group of work begins and ends. TaskGroup and except* require Python 3.11 or later; the site's console uses Python 3.13. Create children with group.create_task inside the context. A successful exit means the children have finished, so it is safe to read their results afterward. This structure is easier to audit than creating detached tasks and hoping some later path remembers to await them.

The example uses sleep(0) to make the coroutine yield without involving a real server or delay. It is still actual asynchronous scheduling. Read both Task objects only after the context closes. Try returning from inside the block and ask whether the group still waits. The context manager's exit handles its children. In larger applications, do not mix unrelated background services into one group whose failure should cancel only related work.

PYTHON
import asyncio
async def job(name):
    await asyncio.sleep(0)
    return name.upper()
async def main():
    async with asyncio.TaskGroup() as group:
        first = group.create_task(job('ada'))
        second = group.create_task(job('grace'))
    print(first.result(), second.result())
asyncio.run(main())
EXPECTED OUTPUT
ADA GRACE
Follow the reasoning
  1. Create related tasks inside async with TaskGroup.
  2. Read results after a successful context exit.
02 / 03

Keep result order separate from finish order

Concurrency changes when tasks finish, not the order in which you originally submitted them. The second task below has no wait while the first waits briefly. If you assemble a result list by completion events, the output can differ between runs. If the caller's input order matters, save the Task objects in an input-order list and then read task.result() after the group has finished. This is a deterministic data contract even when completion timing varies.

Run the example and inspect [1, 2]. Do not infer from that output that task one completed before task two; the list preserves submission order by construction. For an empty input, a TaskGroup with no children still exits and the result list should be empty. The checked exercise uses enumerate to attach a visible number to every label, including duplicates, so order mistakes are easy to spot.

PYTHON
import asyncio
async def job(number, delay):
    await asyncio.sleep(delay)
    return number
async def main():
    async with asyncio.TaskGroup() as group:
        tasks = [group.create_task(job(1, 0.01)), group.create_task(job(2, 0))]
    print([task.result() for task in tasks])
asyncio.run(main())
EXPECTED OUTPUT
[1, 2]
Follow the reasoning
  1. Store each created Task in input order.
  2. Build the result list after the context exits.
03 / 03

Observe cancellation cleanup

A failed child changes the whole group: the TaskGroup cancels unfinished sibling tasks and raises an ExceptionGroup after shutdown. The slow child below has a finally block. When its sibling raises ValueError, cancellation interrupts the slow sleep, but finally still prints its cleanup marker. The outer except* ValueError handles the grouped failure after that cleanup. This is the behavior to test when children hold files, locks, or network resources.

Do not broadly catch and ignore CancelledError inside the worker to keep it running. Structured-concurrency tools use cancellation as a control signal, and swallowing it can make lifetime rules misleading. Place resource release in finally and generally let cancellation propagate. The demonstration sleeps at most one second if a failure never happens, but the failing task yields immediately, so the observed run is quick. It uses no external network and works in the site's Python console.

PYTHON
import asyncio
async def slow():
    try:
        await asyncio.sleep(1)
    finally:
        print('slow cleaned up')
async def fail():
    await asyncio.sleep(0)
    raise ValueError('broken')
async def main():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(slow())
            group.create_task(fail())
    except* ValueError:
        print('group reported failure')
asyncio.run(main())
EXPECTED OUTPUT
slow cleaned up
group reported failure
Follow the reasoning
  1. Fail one child while a sibling is waiting.
  2. Notice finally runs before the ExceptionGroup is handled.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnImplement collect_labels(values) using asyncio.TaskGroup and group.create_task(label(index, text)) with numbering from 1. Return a list of task results in input order after leaving the group. The prepared call must print ['1: ADA', '2: GRACE']; empty input returns [].
Need a hint?

Inside async with asyncio.TaskGroup() as group, build a list of group.create_task(...) calls using enumerate(values, 1). After the block, return [task.result() for task in tasks].

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.When is it safe to read successful task results in this lesson?

    02.What happens to unfinished siblings when one TaskGroup child fails?

    03.How should a worker release resources on cancellation?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Reading results before the group finishes

    COMMON MISTAKE
    task = group.create_task(job())
    print(task.result())
    FIX
    async with asyncio.TaskGroup() as group:
        task = group.create_task(job())
    print(task.result())

    What happens: InvalidStateError may occur because the child has not completed.
    Wait for context exit before reading the result.

    Suppressing cancellation in a child

    COMMON MISTAKE
    try:
        await work()
    except asyncio.CancelledError:
        return None
    FIX
    try:
        await work()
    finally:
        release_resource()

    What happens: The group may observe misleading child state and cleanup semantics.
    Clean up in finally; generally let cancellation propagate.

    Browse this lesson’s problem fixes

    Sources and further reading