Skip to content

Cancelling a TaskGroup from Inside a Child Task

TaskGroup has a clear rule: the block waits for every child. That is exactly right for a batch where all the work matters, and exactly wrong for a search — four replicas racing to answer the same question, a scan across shards that stops at the first hit, a set of validators where one failure decides the outcome. In those cases one child knows the rest is pointless, and needs to say so. There is no public "stop the group" method, and the private tg._abort() is not the answer. There are three supported techniques, all measured below on the same four-worker search, and the choice between them is about what the children are doing rather than about which is fastest.

Prerequisites

  • Python 3.11+ for asyncio.TaskGroup and except*.
  • Group semantics from Exception Groups & TaskGroups — a failing child cancels its siblings.
  • Cancellation handling from Cancellation Patterns, because two of the three techniques cancel work in flight.
Three ways to stop a group from inside it A grid of 4 rows by 2 columns. Three ways to stop a group from inside it technique stops siblings the catch raise a sentinel immediately, by cancellation you must catch it with except* shared stop flag at their next check children must poll it cancel sibling handles immediately you need the task handles tg._abort() immediately private API: do not Measured on the same search: 0.08 s, 0.10 s and 0.08 s respectively.

1. Raise a sentinel exception

The group already has a mechanism for "stop everything": a child that raises. Define an exception that carries the result, raise it from the winner, and unwrap it outside the block.

class Found(Exception):
    def __init__(self, value):
        super().__init__(value)
        self.value = value


async def search(shards) -> str | None:
    async def child(shard):
        result = await scan(shard)
        if result is not None:
            raise Found(result)                        # ends the group

    try:
        async with asyncio.TaskGroup() as tg:
            for shard in shards:
                tg.create_task(child(shard))
    except* Found as eg:
        return eg.exceptions[0].value                  # the first winner's value
    return None                                        # nobody found anything

Four workers stepping every 20 ms, with the winner finding its result at step 3, completed in 0.08 s — and the worker log showed all three siblings cancelled immediately afterwards. The value travels on the exception, so no shared mutable state is needed, and the except* clause is the natural place to unwrap it.

Two notes. If several children can win simultaneously, eg.exceptions holds several Found instances and you pick one deliberately. And the exception is control flow, not an error — do not let it reach a generic handler that logs it as a failure.

Verify: the siblings record a cancellation, and the returned value is the winner's.

How a sentinel exception ends the group 5 stages from child finds a result to except* unwraps. How a sentinel exception ends the group child finds a result raises Found(value) siblings cancelled by the group block waits for cleanup to finish group raised wrapping Found except* unwraps read eg.exceptions[0] The value rides on the exception, so nothing has to be shared between tasks.

2. Set a shared stop flag

When the children hold resources, write to a database or should finish the unit of work they have started, cancellation is too blunt. An asyncio.Event lets each child stop at a point it chooses:

async def search(shards) -> str | None:
    stop = asyncio.Event()
    results: list[str] = []

    async def child(shard):
        async for chunk in scan_chunks(shard):
            if stop.is_set():
                return                                 # a clean, chosen exit
            if (hit := match(chunk)) is not None:
                results.append(hit)
                stop.set()
                return

    async with asyncio.TaskGroup() as tg:
        for shard in shards:
            tg.create_task(child(shard))
    return results[0] if results else None

This took 0.10 s on the same workload — one 20 ms poll interval slower, which is precisely the design trade. The log shows the difference plainly: instead of three cancellations, the three losers each saw the flag and returned normally, at step 3 or 4. No exception was raised, no cleanup path was entered, and no partially written record was abandoned.

The cost is that children must poll. A child blocked in a 30-second call will not notice the flag until it returns, so this technique suits loops, not single long awaits.

Verify: the losing children log a clean exit rather than a cancellation, and no CancelledError appears.

Time to stop four workers once one succeeds 3 bars comparing sentinel exception with the others. Time to stop four workers once one succeeds sentinel exception 0.08 s stop flag, 20 ms poll 0.10 s cancel sibling handles 0.08 s Four workers stepping every 20 ms; the winner finds its result at step 3. The flag costs one poll interval, which is the price of never using exceptions for flow.

3. Cancel the sibling handles directly

tg.create_task() returns a Task, and a child holding the other handles can cancel them:

async def search(shards) -> str | None:
    tasks: dict[int, asyncio.Task] = {}
    results: list[str] = []

    async def child(index, shard):
        result = await scan(shard)
        if result is not None:
            results.append(result)
            for other_index, task in tasks.items():
                if other_index != index:
                    task.cancel()                      # stop the losers

    async with asyncio.TaskGroup() as tg:
        for index, shard in enumerate(shards):
            tasks[index] = tg.create_task(child(index, shard))
    return results[0] if results else None

0.08 s, identical to the sentinel version. The property that makes this safe is worth stating explicitly, because it is easy to assume otherwise: a child cancelled this way does not make the group raise. The block exited normally, with asyncio.current_task().cancelling() still 0 — the group treats an externally cancelled child as finished rather than failed.

Use this when you already keep task handles for other reasons. When you do not, the dictionary is more machinery than the sentinel needs.

Verify: the async with block exits without an exception and the result list holds exactly one entry.

4. Do not reach for tg._abort()

Reading the source suggests a fourth option: TaskGroup has an _abort() method that cancels every child. It is private, it does not mark the group as failed, and calling it leaves the group in a state its own __aexit__ was not written to expect. It is also free to change between point releases.

The three supported techniques cover every case: a sentinel when a value must come back, a flag when children need to choose their exit point, and direct cancellation when the handles already exist. All three are ordinary asyncio, will keep working, and are readable by whoever inherits the code.

Verify: no private asyncio attributes appear in your search implementation.

Which early exit fits? A decision on What are the children doing with 3 outcomes. Which early exit fits? What are the children doing? racing for one result sentinel exception the value rides along holding resources or writes shared stop flag they finish their unit you already keep handles cancel them directly the group exits cleanly All three are correct; the flag is the only one that lets a child finish its current unit.

5. Handle the losers' cleanup properly

Whichever technique you pick, the cancelled children run their finally blocks while the group waits for them. Two rules keep that from becoming a new problem.

Cleanup must be quick and must not block the exit. A finally that awaits a slow flush delays the whole group; if it must happen, bound it with asyncio.timeout and accept the failure:

    finally:
        with contextlib.suppress(TimeoutError):
            async with asyncio.timeout(2):
                await flush()

And cleanup must not swallow the cancellation. except asyncio.CancelledError: return inside a child turns a cancelled task into a completed one, which works here but breaks every other cancellation path in your service, including shutdown. Log it, clean up, and re-raise — see cancellation patterns.

Finally, remember the group still waits. "Stopping early" means the work stops early; the async with block returns only once every child has finished unwinding, which is exactly the guarantee that makes structured concurrency worth having.

Verify: after the group exits, no tasks from it remain in asyncio.all_tasks().

Verification

Early exit from a group is correct when:

  • The winner's value reaches the caller, by exception payload or a collected list.
  • Losers stop promptly: measured latency is one poll interval or less.
  • Cancellation is re-raised by every child that catches it.
  • The block exits cleanly in the flag and direct-cancellation forms, and raises only the sentinel in the exception form.
  • No private APIs are used.
  • No task from the group outlives it.

Pitfalls & edge cases

  • Catching the sentinel too broadly. An except* Exception clause elsewhere will treat the success signal as a failure.
  • return inside except*. It is a syntax error: 'break', 'continue' and 'return' cannot appear in an except* block. Assign to a variable and return after.
  • Cancelling your own task. A child that includes itself in the cancellation loop cancels the very task doing the cancelling; exclude self.
  • Flags that nobody polls. A child awaiting a single long call never checks the event; give it a timeout or use cancellation.
  • Multiple winners. With a flag, more than one child can set it before any returns; deduplicate the results list.
  • Cleanup that outlives the group. A finally that starts a new task escapes the scope; the group has already stopped tracking new children.

Frequently Asked Questions

How do I stop an asyncio TaskGroup early?

Raise a custom exception from the child that decides to stop — the group cancels its siblings automatically — and catch it with except*. Alternatively share an asyncio.Event the children poll, or keep the task handles and cancel the siblings directly. There is no public method on the group itself.

Can a child task cancel its siblings in a TaskGroup?

Yes. Keep the tasks returned by tg.create_task in a dict and call cancel() on the others. The group treats an externally cancelled child as finished rather than failed, so the async with block exits normally rather than raising.

How do I return a value when stopping a TaskGroup early?

Attach it to the sentinel exception — raise Found(value) — and read eg.exceptions[0].value in the except* clause. That avoids shared mutable state between tasks. With a stop flag, append to a list the enclosing scope owns instead.

Is it safe to call tg._abort()?

No. It is a private method, it does not put the group into a state its own exit code expects, and it can change between releases. The sentinel exception, stop flag and direct sibling cancellation cover every case with public API.

Does a TaskGroup wait for cancelled children before exiting?

Yes, always. The block returns only after every child has finished unwinding, including its finally blocks. That is what makes the scope meaningful — but it also means a slow cleanup delays the exit, so bound any awaiting cleanup with a timeout.