Cancel Is a Request, Not a Command

▶ Listen to this article

You have a task running. You call task.cancel(). You move on.

The task keeps running.

This isn’t a bug. It’s how asyncio’s cancellation model works, and understanding why — and what the alternatives look like — changes how you reason about async systems.


When you call task.cancel() in asyncio, it schedules a CancelledError to be raised inside the coroutine at its next await point. The coroutine receives this exception and can respond to it however it likes. It can clean up and let the exception propagate, which is the expected behavior. Or it can catch the exception and continue, which produces what’s sometimes called a zombie task — a task that appeared cancelled but never stopped.

async def stubborn():
    while True:
        try:
            await asyncio.sleep(1)
        except asyncio.CancelledError:
            print("cancelled? no thanks")
            # continues without re-raising

Calling task.cancel() on this coroutine accomplishes nothing. The exception gets swallowed, the task loops again, and nothing external can tell the difference between a running task and a “cancelled” one.

This is what anyio’s documentation calls edge cancellation1: the cancel signal fires once, the task gets to handle it, and the cancellation is “used up” whether or not the task actually stopped. It fires at the edge — a single event — rather than persistently.

CancelledError is a BaseException, not an Exception2, so a bare except Exception: block won’t accidentally swallow it. But an explicit except asyncio.CancelledError: without a re-raise will. The pattern that causes trouble is code that does cleanup on cancellation but forgets to re-raise:

async def process_item(item):
    while not done(item):
        try:
            await step(item)
        except asyncio.CancelledError:
            await cleanup(item)
            return  # cleanup done — but no re-raise

This looks responsible: it cleans up before stopping. But by returning instead of re-raising, the task exits cleanly without propagating the cancellation signal. TaskGroup and asyncio.timeout() rely on CancelledError propagating to know a task was actually cancelled. If you swallow it, they can’t track whether the task stopped because it was cancelled or because it finished normally. The Python docs now explicitly warn: catching CancelledError without re-raising “might misbehave” with TaskGroup and asyncio.timeout(), which use cancellation internally2.

A note on asyncio.timeout() specifically: timeout expiry does not reach the caller as CancelledError. Internally, the timeout mechanism cancels the task with CancelledError, but asyncio.timeout()’s exit logic intercepts this and converts it to a TimeoutError before it propagates outward. External cancellation of the parent task (via task.cancel()) remains CancelledError. The practical implication: if you except asyncio.CancelledError around an asyncio.timeout() block, you’re catching external cancellation — not the timeout itself.


Python 3.11 introduced asyncio.TaskGroup3, which addresses part of this problem with structured concurrency. A task group wraps a set of related tasks and provides cancel-on-exception semantics: if any task in the group fails with an unhandled exception (other than CancelledError), the remaining tasks are cancelled and the exception is propagated to the caller.

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch("https://api.example.com/a"))
        tg.create_task(fetch("https://api.example.com/b"))
        tg.create_task(fetch("https://api.example.com/c"))
    # if any task fails, all others are cancelled
    # exceptions are collected into an ExceptionGroup

This is a substantial improvement over asyncio.gather(), which has the opposite behavior by default: if one coroutine fails, the others keep running as orphans4. Many developers migrating from gather() discover this semantic flip the hard way.

But TaskGroup still uses asyncio’s edge cancellation internally. If a task inside the group catches its CancelledError and doesn’t re-raise, the task group’s cleanup logic can’t reliably stop it. 3.11 also added Task.cancelling() and Task.uncancel() to track cancellation state more precisely, but these are internal machinery — the docs say “user code should not generally call uncancel().”

There’s also the ExceptionGroup requirement: failures from a TaskGroup are wrapped in an ExceptionGroup, which requires Python 3.11+’s except* syntax to catch properly. A bare except ValueError: block will silently not catch a ValueError raised inside a task group. The correct form is except* ValueError:.


Level cancellation works differently. Trio5 and anyio6 implement it: once a cancel scope is cancelled, every subsequent checkpoint raises Cancelled until you exit the scope. You can’t catch your way out. There’s no “used up” event — the cancellation persists.

# trio
async with trio.open_nursery() as nursery:
    nursery.start_soon(do_work)
    nursery.start_soon(do_other_work)
    # if cancel scope is cancelled, every await in both tasks
    # will raise Cancelled until the nursery scope exits

In trio and anyio, the underlying primitive is the cancel scopetrio.CancelScope or anyio.CancelScope. Nurseries and task groups contain cancel scopes; you can also use cancel scopes directly without spawning tasks, for timeouts and other flow control.

anyio’s cancel scope documentation summarizes the distinction: “asyncio employs edge cancellation — a CancelledError is raised in the task and the task then gets to handle it however it likes, even opting to ignore it entirely. In contrast, tasks using anyio cancel scopes use level cancellation — as long as a task remains within an effectively cancelled cancel scope, it will get hit with a cancellation exception any time it hits a yield point.”1

Level cancellation makes coroutines that accidentally suppress cancellation a non-issue for shutdown — the next await will raise again. The tradeoff is that code written to catch and suppress CancelledError may behave unexpectedly when run under trio or anyio.


If you’re debugging an asyncio system and want to know what’s actually running, Python 3.14 added a full call graph introspection module7:

# from inside a running async task:
asyncio.print_call_graph()

# from the shell, without stopping the process:
python -m asyncio pstree <PID>

This prints the full async task tree — which tasks are running, which are awaiting which, and where each task is in the call stack. For production debugging of long-lived async services, this is the clearest window into runtime async state that asyncio has ever had.


One active footgun worth knowing: PEP 7898 (still Draft as of mid-2026) documents a real correctness bug with async generators inside cancel scopes. If you use async for over an async generator while inside a TaskGroup or asyncio.timeout() block, the cancel scope boundary and the generator’s lifetime interact in ways that can leak timeouts to the outer scope or let background tasks escape. The fix hasn’t shipped yet. Trio and anyio are affected too, through the same underlying mechanism. The safest current practice is to avoid async for over async generators inside any cancel scope, and use explicit try/finally in the generator if you must.


The core insight across all of this: async cancellation is a cooperative protocol. No async runtime can forcibly interrupt a coroutine that’s between yield points — the coroutine has to reach an await to be interruptible. This means cancellation is always advisory at the language level.

Where the designs differ is in how robust they make cooperation. asyncio’s edge model trusts coroutines to re-raise CancelledError correctly — useful when they do, fragile when they don’t. trio/anyio’s level model makes cooperation structurally harder to accidentally break — the cancel scope persists until you exit it.

task.cancel() is a request. Whether the task stops depends on whether the code on the other end cooperates. In asyncio, a coroutine that doesn’t cooperate keeps running. In trio or anyio, it gets another chance to cooperate at every subsequent yield — until it leaves the cancel scope.


  1. anyio documentation, “Cancellation”. Defines and explains the edge cancellation vs. level cancellation distinction. anyio v4.13.0, 2026. 

  2. Python 3.14 documentation, “asyncio — Task Cancellation”. Note on CancelledError being a BaseException and the warning about misbehavior with TaskGroup and timeout() when CancelledError is swallowed. 

  3. Python 3.11 What’s New, “asyncio.TaskGroup”. TaskGroup, asyncio.timeout(), Task.cancelling(), and Task.uncancel() all added in 3.11 as part of the structured concurrency push. 

  4. Python 3.14 documentation, asyncio.gather(). With default return_exceptions=False, gather propagates the first exception to the caller but does not cancel remaining tasks. TaskGroup explicitly provides “stronger safety guarantees than gather.” 

  5. trio documentation, “Core — Nurseries and tasks”. trio v0.33.0 (February 14, 2026). Cancel scopes (trio.CancelScope) are the underlying primitive; nurseries contain a cancel scope and are the task-spawning wrapper. 

  6. anyio documentation, “Why use anyio?”. anyio v4.13.0 (March 24, 2026). anyio task groups expose their cancel scope; asyncio.TaskGroup does not. 

  7. Python 3.14 documentation, asyncio.graph — Asynchronous Call Graph Introspection. Added in Python 3.14 (October 2025). Provides asyncio.print_call_graph() for in-process introspection and python -m asyncio pstree <PID> for external inspection of running processes. 

  8. PEP 789, “Preventing task-cancellation bugs by limiting yield in async generators”. Draft (co-authored by Nathaniel J. Smith and Zac Hatfield-Dodds). Documents the correctness bug where async for over async generators inside cancel scopes produces undefined behavior — timeouts can leak to the outer scope, background tasks can escape. Not yet shipped.