Python6 min read

Asyncio Cancellation Is an Exception-Shaped Request

How task cancellation reaches coroutines, interacts with cleanup, and behaves under gather and TaskGroup.

  • asyncio
  • cancellation
  • structured concurrency

Calling task.cancel() does not stop a coroutine between arbitrary bytecode instructions. It arranges for asyncio.CancelledError to be raised in the task, normally at its next suspension point.

async def worker(queue):
    try:
        while True:
            item = await queue.get()
            await process(item)
    finally:
        await close_resources()

The finally block still runs. Cleanup may await, although repeated cancellation or a failing cleanup operation can complicate the result. Cancellation is cooperative and code should keep non-awaiting sections reasonably short.

Cancellation is not an ordinary failure

CancelledError inherits from Exception, so a broad except Exception catches it. Long-running coroutine loops should explicitly re-raise it before logging other errors:

try:
    await operation()
except asyncio.CancelledError:
    raise
except Exception:
    logger.exception('operation failed')

Suppressing cancellation without also clearing the task’s cancellation state can confuse structured-concurrency components. The standard guidance is to propagate it unless the coroutine is deliberately translating the lifecycle.

Waiting helpers choose different policies

asyncio.gather awaits several awaitables and returns results in input order. With its default return_exceptions=False, the first ordinary exception is propagated to the caller.

At that point, gather automatically cancels every unfinished sibling and waits for their cleanup before raising. This gives it the same failure containment as a task group.

With return_exceptions=True, ordinary exceptions become result-list entries. Cancellation has special rules intended to prevent one cancelled child from automatically cancelling the gather operation that contains it.

Task groups make ownership explicit

asyncio.TaskGroup is a structured-concurrency construct. Tasks created inside the group are awaited when the context exits. If one child fails with an ordinary exception, the group cancels the remaining children and ultimately raises an exception group containing failures.

async with asyncio.TaskGroup() as group:
    group.create_task(fetch_profile())
    group.create_task(fetch_activity())

The containing task may be cancelled while inside the block so that control reaches __aexit__, but task-group machinery distinguishes that internal wake-up from cancellation requested by an outside caller.

Shielding separates caller cancellation from child work

asyncio.shield(awaitable) prevents cancellation of the waiting caller from automatically cancelling the protected awaitable. The caller still receives CancelledError; the underlying task may continue. Keep a strong reference to separately created tasks and arrange how their result or exception will be observed.

Cancellation design is ownership design. Decide which scope owns each task, which failures should cancel siblings, and where cleanup is guaranteed. The exception mechanism is the delivery vehicle, not the policy itself.