Go to content
Web development

Handling Cancellation in Python asyncio TaskGroups

When you run several coroutines at once with Python’s asyncio library, you need a way to stop them together when a coroutine fails, the caller gives up, or a deadline passes. asyncio.TaskGroup, added in Python 3.11, is the standard tool for this. A coroutine is a function defined with async def that can pause at await points. A task is asyncio’s wrapper that schedules a coroutine on the event loop, the scheduler that runs all your coroutines. A TaskGroup is an async context manager. You enter it with async with and spawn child tasks with create_task(); the group waits for every child to finish before the block exits.

The group enforces structured concurrency: child tasks cannot outlive the scope that created them. If any child raises an exception, the group cancels the remaining children, waits for them to stop, and then raises the failure to you. When the event loop cancels a task, it throws asyncio.CancelledError into the coroutine at its current await point. The coroutine can catch that exception, run cleanup, and either stop or re-raise.

Put resource release in try/finally and re-raise CancelledError whenever you catch it explicitly; that keeps cancellation safe. By the end of this article you will know how cancellation moves through a TaskGroup, how to write cleanup that runs reliably, how to handle the grouped exceptions a TaskGroup raises, and which patterns prevent tasks from leaking or being silently discarded.

How one failure cancels the whole group

If any task in a TaskGroup raises an exception other than CancelledError, the group cancels all remaining tasks, waits for them to finish, and then raises an ExceptionGroup containing the failure. An ExceptionGroup is a single exception object that carries a list of other exceptions in its .exceptions attribute. The group runs the same shutdown if the body of the async with block itself raises.

import asyncio

async def worker(name: str, delay: float) -> None:
    try:
        print(f"{name} starting")
        await asyncio.sleep(delay)
        print(f"{name} finished")
    finally:
        print(f"{name} cleaned up")

async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(worker("a", 1.0))
        tg.create_task(worker("b", 0.5))
        tg.create_task(worker("c", 0.2))

asyncio.run(main())

All three workers run concurrently, and main() blocks at the end of the async with block until all three finish. Now make one of the workers raise:

async def flaky(name: str, delay: float) -> None:
    await asyncio.sleep(delay)
    raise ValueError(f"{name} failed")

async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(flaky("a", 0.1))
            tg.create_task(worker("b", 5.0))
            tg.create_task(worker("c", 5.0))
    except* ValueError as eg:
        print(f"caught {len(eg.exceptions)} ValueError(s)")

asyncio.run(main())

When flaky("a") raises ValueError, the group cancels "b" and "c". Each task receives CancelledError at its await asyncio.sleep(5.0), runs its finally block, and stops. The group then re-raises the original failure as an ExceptionGroup, and the except* clause catches it.

The group waits for the cancelled siblings to finish their cleanup before it raises. If a sibling swallows the cancellation and keeps working, the group waits for it, so the async with block does not exit. That is the main way a cleanup mistake turns into a hang.

Two details affect what the group raises. CancelledError subclasses BaseException, not Exception, so an except Exception block never catches it; that is usually what you want, because normal error handling should not interfere with cancellation. If a task raises KeyboardInterrupt or SystemExit, the group still cancels and waits for the remaining tasks. It re-raises the original KeyboardInterrupt or SystemExit instead of wrapping it in an ExceptionGroup, so shutdown signals keep their normal meaning.

Cancellation also travels through nested awaits: a CancelledError raised in a child task bubbles up through whatever tasks are awaiting it, and any level in that chain can intercept it. Because of this propagation through the awaiting hierarchy, one cancellation can shut down a whole tree of work.

Writing cleanup that survives cancellation

Use try/finally to make cleanup reliable. Put the resource acquisition before the try, and put the release in finally. The finally block runs whether the task completes, raises, or is cancelled.

import asyncio

lock = asyncio.Lock()

async def worker() -> None:
    await lock.acquire()
    try:
        await asyncio.sleep(1)
    finally:
        lock.release()

When the group cancels this task, CancelledError lands at the current await, the finally block releases the lock, and the cancellation propagates out of the coroutine. You do not need to catch CancelledError for this to work.

If you catch it explicitly, run your cleanup, then re-raise it. Swallowing it tells asyncio the task is not stopping, so a TaskGroup that is shutting down waits for a task that refuses to stop.

async def stubborn_worker() -> None:
    try:
        await asyncio.sleep(1)
    except asyncio.CancelledError:
        print("ignoring cancellation")
        await asyncio.sleep(1)  # the group waits for this to finish

async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(stubborn_worker())
        await asyncio.sleep(0.1)
        raise RuntimeError("shut down")

The group tries to cancel stubborn_worker, but the task catches CancelledError and sleeps for another 1 second. The group cannot exit until the task stops, so the RuntimeError is delayed by the full 1 second. The fix is to re-raise after cleanup:

async def stubborn_worker() -> None:
    try:
        await asyncio.sleep(1)
    except asyncio.CancelledError:
        await release_resource()
        raise

The same rule applies inside a group body: if the body catches CancelledError and returns normally, the group can misbehave, because components like TaskGroup use cancellation internally and expect it to propagate.

Cleanup itself can be interrupted: if a finally block awaits a slow operation and a second cancellation arrives, asyncio throws the second CancelledError into the finally block, and the cleanup never completes. For short, critical cleanup, wrap just the cleanup call in asyncio.shield(), which protects the inner awaitable from cancellation of the surrounding task:

async def worker() -> None:
    try:
        await long_work()
    finally:
        await asyncio.shield(release_resource())

Keep shields tiny and reserve them for cleanup. If you wrap the main work in a shield, cancellation no longer stops the task promptly, and you have built a hang instead of a cleanup.

Async generators need the same care: if a task is cancelled while iterating one, the generator’s own finally does not run unless something awaits its aclose() method. Use an async with helper that closes the generator, or call aclose() in your own finally, so the generator’s resources are released on cancellation.

Treat cancellation as a contract in your own code. Stop promptly when you receive CancelledError, and keep the cancellation routable by re-raising it after local cleanup so it reaches the group and the caller. Make cleanup idempotent as well, safe to run more than once, because a task can be cancelled while it is already cleaning up. Together, these three properties keep a TaskGroup shutdown fast and leak-free.

Handling the ExceptionGroup a TaskGroup raises

When several tasks fail, the group aggregates the failures into one ExceptionGroup. Handle it with except*, which matches a type anywhere inside the group. The handler receives an ExceptionGroup containing the matching exceptions. Iterate eg.exceptions to process each one. Exceptions that do not match continue propagating.

import asyncio

async def maybe_fail(name: str) -> None:
    if name == "a":
        raise ValueError(f"{name} failed")
    await asyncio.sleep(1)

async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(maybe_fail("a"))
            tg.create_task(maybe_fail("b"))
    except* ValueError as eg:
        for exc in eg.exceptions:
            print(f"handled: {exc}")

asyncio.run(main())

If a task was cancelled because a sibling failed, that CancelledError is not reported to you. The group treats the cancellation of siblings as an internal consequence of the failure and surfaces only the original error. except* cannot be combined with a plain except in the same try statement, so if you need both, nest the try blocks.

In a shutdown path, do not swallow the whole group. If you write except ExceptionGroup: pass or catch the group and log nothing, you drop every failure inside it. Log the group, or re-raise it after recording what you need.

Preventing discarded and leaked tasks

A task can be cancelled before its coroutine ever runs. If a group is shut down before a newly created task is scheduled, asyncio cancels the task without executing the coroutine at all, not even its first line. Any cleanup inside that coroutine does not run, because the coroutine never started.

import asyncio

async def never_runs() -> None:
    print("this line never executes")
    try:
        await asyncio.sleep(1)
    finally:
        print("this cleanup never runs either")

async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(never_runs())
        raise RuntimeError("group shuts down immediately")

asyncio.run(main())

The RuntimeError fires before the event loop gets a chance to start never_runs, so the group silently discards the task. The Task.cancel documentation historically implied that CancelledError is always thrown into the coroutine, but a task that has not started is cancelled without running, and the docs were corrected.

To guarantee that a coroutine starts even when the group is shutting down, create the task eagerly with eager_start=True or install asyncio.eager_task_factory for the whole loop. Eager tasks begin running immediately at creation, before the group can discard them. One caution: Python 3.12 shipped with a bug in its eager-task optimization that let cancellation leak out of a TaskGroup, meaning a cancellation meant for a child could surface in the parent scope. The fix removed that optimization, so if you use eager tasks on 3.12, use a patched patch release.

The same never-started rule explains why you should not create tasks after the group body has finished. Calling create_task() on a group that is not active, meaning it has not been entered, has already exited, or is shutting down, raises RuntimeError. Since Python 3.13, the group also closes the coroutine you passed in. Create everything inside the body, and do not try to add work from a callback that fires after the body returns.

A separate leak path has nothing to do with TaskGroup. With plain asyncio.create_task(), you must keep a strong reference to the returned task; otherwise the garbage collector can reclaim it mid-flight and its exceptions are lost. A TaskGroup keeps strong references to every child and awaits them, which removes that entire class of bug.

Cancelling the whole group

To stop every child without waiting for a specific failure, for example during a server shutdown, Python 3.15 adds TaskGroup.cancel. It cancels all unfinished tasks and the parent body, then lets the context manager exit without raising CancelledError. On that version, you can write:

async def worker(name: str) -> None:
    try:
        await asyncio.sleep(30)
    finally:
        print(f"{name} cleaned up")

async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(worker("a"))
        tg.create_task(worker("b"))
        tg.cancel()
    print("all tasks cancelled")

On Python 3.11 through 3.13, TaskGroup has no cancel() method. To work around this, wrap the group in its own task and cancel that outer task. The CancelledError enters the group body. The group cancels its children, waits for their cleanup, and then propagates the cancellation:

import asyncio

async def worker(name: str) -> None:
    try:
        await asyncio.sleep(30)
    finally:
        print(f"{name} cleaned up")

async def run_group() -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(worker("a"))
        tg.create_task(worker("b"))
        await asyncio.sleep(30)

async def main() -> None:
    group_task = asyncio.create_task(run_group())
    await asyncio.sleep(1)
    group_task.cancel()
    try:
        await group_task
    except asyncio.CancelledError:
        print("group cancelled cleanly")

asyncio.run(main())

The same outer-task pattern works when you want a deadline for a whole group. Wrap the group in a task and use asyncio.timeout() on it. asyncio.timeout() (Python 3.11+) cancels the awaited operation when the deadline passes and raises TimeoutError to the caller; the inner coroutines still receive CancelledError, so their finally blocks still run.

Bounding concurrency and choosing between TaskGroup and gather

A TaskGroup runs every task you create immediately, so pair it with an asyncio.Semaphore to limit how many operations run at once. A semaphore is a counter that limits how many coroutines can pass through a section at once. Each task acquires the semaphore before doing its work, so at most N tasks hold it at a time:

async def fetch_one(sem: asyncio.Semaphore, url: str) -> None:
    async with sem:
        await fetch(url)

async def main() -> None:
    sem = asyncio.Semaphore(5)
    async with asyncio.TaskGroup() as tg:
        for url in urls:
            tg.create_task(fetch_one(sem, url))

All tasks are created up front, but the semaphore lets only five run at a time; the rest wait. The group still waits for all of them, and if any task fails, the group cancels the others just as before.

TaskGroup does not replace asyncio.gather() in every situation. gather() works well when you need results as soon as they arrive and the tasks do not schedule their own child tasks. For shutdown paths, prefer a TaskGroup, because gather() without return_exceptions=True can mask cancellations, and return_exceptions=True only helps if you actually inspect the returned results. The documentation discussion around TaskGroup makes the same point: it is a structured-concurrency tool, not a universal replacement.

Gotchas across backends and libraries

In AnyIO, a library that runs the same async API on asyncio or Trio, cancelling a TaskGroup.start call behaves differently on each backend. The asyncio backend cancels the entire task group; the Trio backend cancels only the task being started. Code that runs on both backends should not rely on start() cancellation semantics.

A third-party coroutine that swallows CancelledError inside a task can stall your TaskGroup exit, because the group waits for every child to stop. If a library coroutine hangs on shutdown, check whether it catches BaseException or CancelledError and fails to re-raise. Apply the same review to your own broad handlers: put except asyncio.CancelledError: raise before any blanket handler so the blanket handler never sees it.

More in Web development Back to top ↑