Python Asyncio Task Cancellation
Python’s asyncio task cancellation is a cooperative way to stop a coroutine: calling cancel() asks the task to raise CancelledError at its next opportunity, usually an await. The coroutine can let that exception propagate, handle it, or suppress it.
Why cancellation is cooperative
An asyncio task runs Python code on an event-loop thread. Unlike killing a thread or process, cancelling a task does not abruptly stop execution at an arbitrary machine instruction. Abrupt interruption could leave a lock held, a file half-written, or an application invariant broken.
Without a cancellation mechanism, code waiting for a network response or an indefinitely sleeping operation would keep running until the operation completed. Callers would have no reliable way to enforce a timeout, shut down a service, or abandon work after a client disconnected.
Cancellation provides that control while giving the coroutine a chance to release resources and preserve consistent state.
What Task.cancel() does
Calling cancel() on a task does not immediately throw an exception into the current Python call stack. It requests cancellation from the event loop:
async def fetch_with_timeout(client, url):
task = asyncio.create_task(client.get(url))
try:
return await asyncio.wait_for(task, timeout=2)
finally:
if not task.done():
task.cancel()
When the task next gets an opportunity to run, asyncio raises asyncio.CancelledError inside the coroutine. In ordinary code, that opportunity is an await point. If the coroutine is currently awaiting a Future—the event-loop object representing a result that will be available later—asyncio also cancels that awaited operation where possible, then resumes the coroutine by raising CancelledError at the await.
The exception is therefore delivered inside the coroutine, as though the await had raised it:
async def worker():
print("started")
await asyncio.sleep(60)
print("this may never print")
If another task calls worker_task.cancel() while it is sleeping, the sleep is interrupted and CancelledError is raised at that await.
A task that never reaches an await point cannot be usefully cancelled while it is running. This loop blocks the event loop, so cancellation cannot be delivered until it returns—and it never does:
async def bad_worker():
while True:
do_cpu_work() # no await: other tasks cannot run
For CPU-heavy work, the code must periodically yield, for example with an appropriate scheduling point, or move the work to a thread or process. Cancellation is cooperative because the event loop needs the coroutine to give it control.
How cancellation propagates
If a coroutine does not catch CancelledError, normal exception propagation takes over. Its finally blocks run, then the exception travels to the code awaiting the task:
async def run_job():
await asyncio.sleep(10)
return "done"
async def caller():
task = asyncio.create_task(run_job())
task.cancel()
try:
await task
except asyncio.CancelledError:
print("the job was cancelled")
The cancelled task finishes in a cancelled state, and await task raises CancelledError. This is why cancellation often appears in logs as an exception even though it is normally expected control flow.
Cancellation also moves through nested awaits. If task A is awaiting task B and A is cancelled, asyncio generally cancels B as well. If B is cancelled or propagates its own cancellation, A receives CancelledError at its await. This prevents a parent operation from being declared stopped while its child continues doing the work the parent abandoned.
The exact relationship can be changed with tools such as asyncio.shield(), which protects an inner awaitable from cancellation by its outer task. Shielding does not make the outer task immune: the outer await can still raise CancelledError, while the protected operation continues independently. Use it only when that separation is deliberate.
Catching and suppressing cancellation
A coroutine may catch CancelledError to perform special handling. Usually it should re-raise the exception after cleanup:
async def serve(connection):
try:
await handle_requests(connection)
except asyncio.CancelledError:
log_shutdown(connection)
raise
finally:
connection.close()
The raise matters. If code catches CancelledError and returns normally, it has suppressed the cancellation. The caller may believe the operation stopped, while the coroutine has instead completed successfully or continued doing work:
async def misleading_worker():
try:
await do_work()
except asyncio.CancelledError:
print("ignoring cancellation")
return "success" # cancellation was suppressed
Suppression is occasionally valid—for example, when a component intentionally converts cancellation into a different result—but it should be explicit. In modern Python, CancelledError inherits from BaseException, not ordinary Exception, so a broad except Exception does not normally swallow it. That distinction helps cancellation pass through generic error-handling code.
Why cleanup still runs
CancelledError is an exception, and Python’s exception unwinding rules still apply. As the exception leaves each stack frame, Python executes that frame’s finally block. Async context managers also get their exit method called, allowing them to release locks, close connections, or roll back transactions.
async def write_report(db):
transaction = await db.begin()
try:
await generate_and_store_report(transaction)
await transaction.commit()
finally:
await transaction.close()
If the task is cancelled during generate_and_store_report(), control enters finally, so transaction.close() still runs. After the cleanup completes, CancelledError continues outward unless the cleanup or surrounding code suppresses it.
Cleanup itself can contain an await, and another cancellation request can interrupt that cleanup. Critical cleanup should therefore be designed to tolerate interruption, and code that intentionally must finish independently may use shielding with care. The central rule remains simple: catch cancellation only to clean up or make a deliberate decision; otherwise re-raise it.
This mechanism explains the familiar CancelledError stack trace, timeout behavior, and graceful shutdown logs in asyncio services. Cancellation is not a forced deletion of a task. It is an exception request delivered at a yield point, propagated through cooperating coroutines, with Python’s normal cleanup guarantees applied along the way.