Skip to content

Cancelling Tasks with a Message

Task.cancel(msg=...) attaches a reason to a cancellation: the CancelledError raised inside the task carries the message in args, and so does the error seen by whoever awaits the task. That turns "this task was cancelled" in a log into "this task was cancelled because of shutdown" or "because the client disconnected" — exactly what you need when debugging why work stopped. Tested on Python 3.11 through 3.14 with identical results: the message "shutdown: deploy 42" arrived inside the task and at the awaiter; a coroutine further up the same task saw it too; cancelling a gather with a message delivered it to every child. Some cancellations carry no message at all: timeouts from wait_for and asyncio.timeout, and sibling cancellations by a failing TaskGroup, all produced an empty args. And when a task was cancelled twice with different messages, the first message won. This guide uses messages to make cancellations explain themselves.

Prerequisites

1. Pass a reason when you cancel

Any code that cancels a task knows why. Say so:

task.cancel(msg="shutdown: SIGTERM received")

# elsewhere
worker.cancel(msg=f"rebalance: partition {partition} revoked")
request_task.cancel(msg="client disconnected")

The message is a string by convention; any object works, but strings serialize cleanly into logs. Keep messages short and structured — a reason category and a detail — so they can be grouped in log queries. The message does not change cancellation semantics at all: the task is cancelled exactly as it would be without it.

Verify: each place in your codebase that calls .cancel() passes a message naming the reason.

2. Read the reason inside the task and at the awaiter

Inside the cancelled task, the reason is in the exception's args; the awaiting code receives a CancelledError with the same args:

async def worker() -> None:
    try:
        await consume_forever()
    except asyncio.CancelledError as exc:
        reason = exc.args[0] if exc.args else "unspecified"
        log.info("worker stopping: %s", reason)            # measured: "shutdown: deploy 42"
        await asyncio.shield(flush_state())
        raise


task = asyncio.create_task(worker())
...
task.cancel(msg="shutdown: deploy 42")
try:
    await task
except asyncio.CancelledError as exc:
    log.info("worker cancelled: %s", exc.args)              # measured: ('shutdown: deploy 42',)

Tested: both sides saw ('shutdown: deploy 42',), and an outer coroutine that awaited an inner one within the same task saw the message as the error propagated up through it. Always re-raise after reading the reason; the message is information, not permission to swallow the cancellation, as covered in understanding Task.cancelling() and uncancel().

Verify: the shutdown log shows each worker's stop reason, matching the code path that cancelled it.

Where the cancel message arrives A grid of 6 rows by 3 columns. Where the cancel message arrives cancellation source message seen args task.cancel(msg) - inside task yes ('shutdown: deploy 42',) task.cancel(msg) - awaiter yes ('shutdown: deploy 42',) gather(...).cancel(msg) - children yes ('cancel gather',) wait_for / asyncio.timeout expiry no () TaskGroup cancelling siblings no () two cancels, two messages first wins ('first',) Identical on Python 3.11, 3.12, 3.13 and 3.14.

3. Know which cancellations carry no message

Some cancellations come from the standard library, which does not set a message. Tested on all four versions: a coroutine timed out by asyncio.wait_for or asyncio.timeout saw args == (), and so did a task cancelled by its TaskGroup because a sibling failed:

def describe(exc: asyncio.CancelledError) -> str:
    if exc.args:
        return str(exc.args[0])                         # an explicit reason from our own code
    task = asyncio.current_task()
    if task is not None and task.cancelling() == 0:
        return "cancelled and already uncancelled"
    return "timeout, task group or untagged cancel"      # no message available

The absence of a message is itself informative once your own code always sets one: an empty args then means the cancellation came from a timeout, a task group, or a library. For timeouts, the caller sees TimeoutError anyway, which is where that reason belongs, as discussed in telling TimeoutError apart from CancelledError.

Verify: log lines for cancellations with empty args correlate with timeouts or task-group failures in the same request.

Where did this cancellation come from? A decision on What does exc.args contain with 4 outcomes. Where did this cancellation come from? What does exc.args contain? a reason string our own cancel(msg) log it, re-raise empty, caller got TimeoutError wait_for / asyncio.timeout timeout reason empty, caller got ExceptionGroup TaskGroup sibling failed see the group empty, nothing else library or untagged cancel add a message Once your own cancels always carry a reason, silence is a clue too.

4. Remember that the first message wins

A task can be asked to stop more than once before it gets the chance to run. Tested: cancel("first") followed by cancel("second") delivered ('first',) inside the task and at the awaiter:

task.cancel("client disconnected")
task.cancel("shutdown")                # does not replace the first reason

# If a later reason must take priority, record it separately
shutdown_reason: dict[asyncio.Task, str] = {}

def cancel_for_shutdown(task: asyncio.Task) -> None:
    shutdown_reason[task] = "shutdown"
    task.cancel("shutdown")

The first request is the one that actually wakes the task, so its message is the one carried by the exception. When several parts of a system may cancel the same task — a request watchdog and a shutdown coordinator — and you need to know all of them, keep a side record keyed by task, or design ownership so only one component cancels each task.

Verify: a test that cancels twice asserts the first message, so a future change of behaviour is noticed.

5. Use messages in shutdown and supervision code

Messages pay off most where many tasks are cancelled at once and someone has to work out afterwards what happened:

async def shutdown(tasks: set[asyncio.Task], reason: str, grace: float = 10.0) -> None:
    for task in tasks:
        task.cancel(msg=f"shutdown: {reason}")
    done, pending = await asyncio.wait(tasks, timeout=grace)
    for task in done:
        if task.cancelled():
            continue                                    # stopped as asked
        if exc := task.exception():
            log.error("task %s failed during shutdown", task.get_name(), exc_info=exc)
    for task in pending:
        log.warning("task %s ignored cancellation (%s)", task.get_name(), reason)


async def supervise(name: str, factory) -> None:
    while True:
        task = asyncio.create_task(factory(), name=name)
        try:
            await task
        except asyncio.CancelledError as exc:
            if task.cancelled() and not asyncio.current_task().cancelling():
                log.warning("%s cancelled from elsewhere: %s; restarting", name, exc.args)
                continue
            raise

Naming tasks (create_task(..., name=...)) and giving reasons together make shutdown logs readable: "consumer-3 stopping: shutdown: SIGTERM" instead of a bare traceback. The supervisor distinguishes "my child was cancelled by someone else" (restart it) from "I am being cancelled" (stop), using the counter from the previous guide.

Verify: a rolling restart produces one stop line per task with the shutdown reason, and no anonymous CancelledError tracebacks.

A cancellation that explains itself A flow of 5 stages. A cancellation that explains itself task.cancel(msg=reason) owner knows why except CancelledError exc.args[0] log with task name shielded cleanup re-raise never swallow awaiter sees args same reason One string turns an anonymous stop into a diagnosable event.

Verification

Cancellation messages are used well when:

  • Every cancel() call in your code passes a reason.
  • Handlers read exc.args, log it, and re-raise.
  • Empty args are understood as timeouts, task groups or library cancels.
  • Shutdown and supervision logs name each task and its stop reason.

Diagnostic Hook: count cancellations by reason (from exc.args) and by task name. An unexpected rise in "client disconnected" points at client-side timeouts or proxies; many cancellations with no message point at timeouts and task-group failures — look for the corresponding TimeoutError or exception group in the same request.

Pitfalls & edge cases

  • Expecting a message from timeouts. wait_for and asyncio.timeout sent none in testing.
  • Expecting the latest reason. The first of two messages won.
  • Swallowing after reading the reason. The message never makes swallowing safe.
  • Unnamed tasks. The reason without the task name is half the story.

Frequently Asked Questions

How do I pass a reason when cancelling an asyncio task?

Call task.cancel(msg="reason"). The CancelledError inside the task and the one seen by whoever awaits the task both carry the message in args, as tested on Python 3.11 to 3.14.

Why is CancelledError.args empty?

The cancellation came from code that did not set a message: asyncio.wait_for or asyncio.timeout expiring, or a TaskGroup cancelling siblings after a failure. All produced empty args in testing.

What happens if a task is cancelled twice with different messages?

The first message is delivered; in testing cancel("first") then cancel("second") produced ('first',) inside the task and at the awaiter.

Does cancelling asyncio.gather pass the message to its children?

Yes. Cancelling the gather future with a message delivered that message to every child in testing.