Every awaitable API has two result paths even when its signature mentions one: it may produce a value, or its caller may stop waiting. Cancellation decides whether that second path promptly releases resources, leaves work running, converts into another error, or disappears.

Treating cancellation as an exceptional afterthought creates hanging shutdowns and leaked transactions. Treating it as a guaranteed interrupt is equally dangerous. In asyncio, cancellation is a cooperative protocol delivered at suspension points. Code gets a chance to clean up, and code can accidentally suppress the request.

This tutorial focuses on the contract a coroutine exposes. It distinguishes caller cancellation from deadlines, shows when shielding is justified, and makes cleanup behavior testable without relying on races.

Version note. Examples were run on CPython 3.14.7. TaskGroup, asyncio.timeout, cancellation counts, and Task.uncancel() reflect modern asyncio, with important refinements across Python 3.11 through 3.14. Cancellation is an asyncio API guarantee, not a CPython bytecode interrupt or operating-system thread cancellation.

Experiment 1: cancellation arrives at an await

Pyodide / WebAssembly
import asyncio

async def worker(started):
    started.set()
    try:
        await asyncio.sleep(3600)
    finally:
        print('[event] worker cleanup')

async def main():
    started = asyncio.Event()
    task = asyncio.create_task(worker(started))
    await started.wait()
    assert task.cancel() is True
    try:
        await task
    except asyncio.CancelledError:
        print('[result] worker cancelled')
    assert task.cancelled()

asyncio.run(main())

Task.cancel() requests that CancelledError be thrown into the wrapped coroutine on the next event-loop cycle. Here the task is suspended in sleep, so it can run finally and then finish cancelled. Calling cancel() is not the same as synchronously terminating work; await the task to observe completion and cleanup.

asyncio.CancelledError directly subclasses BaseException, not Exception. Ordinary except Exception logging therefore does not swallow it. Broad except BaseException, a bare except, or an explicit cancellation handler still can.

Python 3.14 guarantee. Cancellation is cooperative. cancel() arranges for CancelledError to be raised and may be denied if the coroutine catches it. Unlike Future.cancel(), Task.cancel() does not guarantee the task becomes cancelled.

Experiment 2: CPU work delays the request

import asyncio

async def worker(reached_checkpoint):
    for number in range(200_000):
        _ = number * number
    reached_checkpoint.set()
    await asyncio.sleep(0)

async def main():
    checkpoint = asyncio.Event()
    task = asyncio.create_task(worker(checkpoint))
    await checkpoint.wait()
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass
    assert task.cancelled()

asyncio.run(main())

The event loop cannot run the caller while worker executes its uninterrupted loop. Cancellation is requested only after the worker reaches an await. Longer CPU sections create event-loop latency for every task, not just slow cancellation.

Adding await asyncio.sleep(0) checkpoints can make an inherently async algorithm responsive, but do not scatter them as performance folklore. Move substantial CPU work out of the loop or break it into meaningful bounded chunks. A checkpoint defines a new interruption boundary; invariants must be valid there.

An awaited blocking thread is different. Cancelling the asyncio task stops waiting on to_thread, but cannot forcibly stop a function already running in the thread. The function needs its own timeout or cooperative stop signal.

Experiment 3: finally owns resource cleanup

import asyncio

async def use_resource(opened, closed):
    opened.set()
    try:
        await asyncio.sleep(3600)
    finally:
        await asyncio.sleep(0)
        closed.set()

async def main():
    opened = asyncio.Event()
    closed = asyncio.Event()
    task = asyncio.create_task(use_resource(opened, closed))
    await opened.wait()
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass
    assert closed.is_set()

asyncio.run(main())

Acquisition and release belong in one try/finally region, usually behind an async context manager. The caller should not need to know which halfway states exist. A transaction API should document whether cancellation rolls back, commits an already accepted operation, or returns an identifier for later reconciliation.

Cleanup may await. The cancellation exception currently unwinds through the finally block, so these cleanup awaits normally run. A second cancellation request can interrupt them. That is useful during forced shutdown but means cleanup must be idempotent and systems need escalating shutdown phases: stop intake, request cancellation, wait a grace period, then abandon or force-close resources.

Never turn cancellation into success merely because cleanup worked. Cleanup restores invariants; propagation tells the caller its requested operation did not complete normally.

Experiment 4: swallowing cancellation lies to callers

import asyncio

async def broken():
    try:
        await asyncio.sleep(3600)
    except asyncio.CancelledError:
        return 'pretend success'

async def main():
    task = asyncio.create_task(broken())
    await asyncio.sleep(0)
    task.cancel()
    assert await task == 'pretend success'
    assert not task.cancelled()
    assert task.cancelling() == 1

asyncio.run(main())

The task returns a value despite an outstanding cancellation request. Structured-concurrency tools use cancellation internally, so swallowing it can confuse timeout and task-group behavior. The normal pattern is cleanup followed by raise:

import asyncio

async def responsible(events):
    try:
        await asyncio.sleep(3600)
    except asyncio.CancelledError:
        events.append('cleanup')
        raise

async def main():
    events = []
    task = asyncio.create_task(responsible(events))
    await asyncio.sleep(0)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass
    assert events == ['cleanup']
    assert task.cancelled()

asyncio.run(main())

Python exposes Task.uncancel() for the rare code that intentionally suppresses CancelledError and wants to remove a cancellation request. Application code almost never needs it. If an API promises an uncancellable operation, that promise needs a precise boundary and shutdown consequence, not a hidden uncancel() call.

Experiment 5: deadlines translate cancellation

import asyncio

async def slow(closed):
    try:
        await asyncio.sleep(3600)
    finally:
        closed.set()

async def main():
    closed = asyncio.Event()
    try:
        async with asyncio.timeout(0.01):
            await slow(closed)
    except TimeoutError:
        pass
    else:
        raise AssertionError('deadline should expire')
    assert closed.is_set()

asyncio.run(main())

asyncio.timeout cancels the current task when its deadline expires. Its context manager catches the resulting CancelledError and translates it to built-in TimeoutError outside the context. Catch TimeoutError outside, not inside, if you mean to handle expiration.

A timeout and caller cancellation communicate different facts. Timeout means this scope's clock expired. Caller cancellation means a parent no longer wants the operation, perhaps because the client disconnected or the service is shutting down. Converting every CancelledError to TimeoutError destroys that distinction.

Deadlines compose better than layers of independent relative timeouts. If a request has 500 milliseconds total, each downstream call should receive the remaining budget, not start a fresh 500 milliseconds. timeout_at() accepts the loop's absolute clock for this purpose.

Experiment 6: wait_for waits for cancellation cleanup

import asyncio
from time import perf_counter

async def slow_cleanup():
    try:
        await asyncio.sleep(3600)
    finally:
        await asyncio.sleep(0.02)

async def main():
    started = perf_counter()
    try:
        await asyncio.wait_for(slow_cleanup(), timeout=0.01)
    except TimeoutError:
        elapsed = perf_counter() - started
    assert elapsed >= 0.025
    print(round(elapsed, 3))

asyncio.run(main())

Since Python 3.7, wait_for waits until the inner awaitable is actually cancelled, so elapsed time can exceed the nominal timeout while cleanup runs. Since Python 3.11 it raises built-in TimeoutError rather than asyncio.TimeoutError. Both are version-specific details worth remembering when maintaining older code.

The extra wait is usually correct: returning while cleanup still mutates shared state would create a race. It also means a timeout is not a wall-clock upper bound unless cancellation cleanup itself is bounded. Operational deadlines should reserve cleanup time and distinguish a graceful deadline from a hard process-level limit.

Experiment 7: shielding separates caller lifetime from work lifetime

import asyncio

async def commit(done):
    await asyncio.sleep(0.01)
    done.set()
    return 'committed'

async def main():
    done = asyncio.Event()
    inner = asyncio.create_task(commit(done))
    outer = asyncio.create_task(asyncio.shield(inner)) if False else None

    async def caller():
        return await asyncio.shield(inner)

    outer = asyncio.create_task(caller())
    await asyncio.sleep(0)
    outer.cancel()
    try:
        await outer
    except asyncio.CancelledError:
        pass
    assert await inner == 'committed'
    assert done.is_set()

asyncio.run(main())

Cancelling outer does not cancel the shielded inner; the caller still receives CancelledError. Shielding is suitable only when work has crossed a boundary after which abandoning it is less safe than finishing it: perhaps a tiny atomic commit or protocol close. Keep a strong reference to the inner task, as the event loop holds only weak references to tasks.

Shielding can outlive request scopes and consume capacity after clients leave. The continued task needs an owner, result observation, deadline, and shutdown policy. Often the better design is durable job submission: acknowledge acceptance, persist an operation ID, and let a worker own completion.

Experiment 8: cancellation is observable state

import asyncio

async def worker(ready):
    ready.set()
    await asyncio.Event().wait()

async def main():
    ready = asyncio.Event()
    task = asyncio.create_task(worker(ready), name='index-refresh')
    await ready.wait()
    assert task.cancelling() == 0
    task.cancel('service shutdown')
    assert task.cancelling() == 1
    try:
        await task
    except asyncio.CancelledError as error:
        assert error.args == ('service shutdown',)
    assert task.done() and task.cancelled()

asyncio.run(main())

Names and cancellation messages help diagnostics, but messages are not a typed public protocol. Record the structural reason at the cancellation source: deadline expired, parent failed, client disconnected, or shutdown began. Metrics should separate expected cancellation from faults rather than logging every CancelledError as an exception.

The cancelling count preserves multiple requests. Python 3.13 improved simultaneous internal and external cancellation handling in task groups, and 3.14 preserves those semantics. Do not build business logic around exact counts; use them for task machinery and diagnostics.

Specify the contract

For each public coroutine, answer:

  • At which awaits can cancellation be observed?
  • Which effects may already have happened?
  • Who cleans up acquired resources?
  • Is cleanup bounded and idempotent?
  • Does the coroutine propagate cancellation unchanged?
  • Can underlying thread, process, or remote work continue?
  • Is any region deliberately shielded, and who owns it afterward?

Cancellation across system boundaries

Local cancellation cannot retract bytes already sent to another process. If an HTTP request is cancelled after the server accepts it, closing the client socket may save response work, or the server may finish the mutation anyway. Database drivers differ in whether cancellation sends a protocol-level cancel, abandons a connection, or merely stops the awaiting task. Read that library's contract and test against the deployed server version.

Design remote mutations around uncertainty. Give operations idempotency keys, return durable identifiers at acceptance, and provide status lookup. Then cancellation means "this caller stopped observing" rather than the false claim "the operation did not happen." For read-only work, cancellation can still consume server capacity until transport closure is detected, so propagate deadlines in request metadata where protocols support them.

Shutdown is coordinated cancellation

A service shutdown usually has at least two deadlines. The graceful phase stops accepting new work and lets existing operations finish or respond to cancellation. The hard phase closes resources or terminates the process because deployment safety now outweighs individual cleanup. Express both durations in configuration and expose metrics for tasks remaining at each transition.

Avoid cancelling every task returned by asyncio.all_tasks() indiscriminately inside library code. That set includes framework and supervisor tasks the library does not own. Retain handles or task groups for children you create. Ownership determines cancellation authority.

Cancellation logs should emphasize the source and latency of cleanup. A normal client disconnect is usually not an error traceback. Cleanup exceeding its grace period is operationally important. Count both, and preserve exceptions raised during cleanup rather than replacing them with a generic cancellation message. This makes cancellation a diagnosable lifecycle instead of noise filtered from logs.

An API that creates a remote payment cannot honestly promise "nothing happened" when its caller is cancelled after sending bytes. It can promise idempotency keys, status lookup, and reconciliation. Cancellation contracts are distributed-systems contracts once effects leave the process.

Exercises

  1. Add a second cancellation during Experiment 3 cleanup. Make release idempotent and document the resulting state.
  2. Wrap a to_thread call in a timeout and prove the thread continues. Add a cooperative threading.Event stop signal.
  3. Replace relative nested timeouts with one timeout_at deadline passed through three coroutine layers.
  4. Design a shielded commit API with explicit task ownership, result logging, and a shutdown deadline.
  5. Test one production coroutine by synchronizing with events, cancelling at each meaningful await, and asserting its external effects.

Keep this model

Cancellation is a request delivered through coroutine scheduling, not an interrupt that rewinds effects. A responsible coroutine keeps invariants valid at suspension points, owns cleanup in finally, and normally re-raises CancelledError. Timeouts use cancellation but translate it at a defined scope. Shielding changes ownership rather than making code magically safe.

When cancellation behavior is undocumented, callers guess whether work stopped. Make the answer part of the API, especially where threads, remote systems, transactions, or durable effects continue beyond the awaiting task.

Primary sources