Python Async Task Cancellation: How to Cancel Tasks
python async task cancellation: Learn how to cancel asyncio tasks in Python, handle CancelledError, and use timeouts, shielding, and TaskGroup for robust concurrency.
Python async task cancellation is a core feature of asyncio that lets you stop a coroutine before it finishes. When you cancel a task, asyncio raises CancelledError inside the coroutine at the next await point. Handling that exception correctly is essential for writing reliable concurrent code.
The Basics of Task Cancellation
A task in asyncio wraps a coroutine and schedules it on the event loop. To cancel a task, you call its cancel() method. This does not immediately stop the coroutine; it schedules a CancelledError to be thrown into the coroutine at the next suspension point.
import asyncio async def worker(): print("start") await asyncio.sleep(10) print("end") async def main(): task = asyncio.create_task(worker()) await asyncio.sleep(0.1) task.cancel() try: await task except asyncio.CancelledError: print("task was cancelled") asyncio.run(main())
The await task line is where the cancellation is observed. If the task is cancelled, await task raises CancelledError in the awaiting coroutine. This is how cancellation propagates from the task to the code that awaits it.
How Cancellation Propagates Through Coroutines
When a task is cancelled, the CancelledError is raised inside the coroutine at the current await point. If that coroutine is awaiting another coroutine, the exception propagates through the entire chain. Each coroutine in the chain gets a chance to handle the cancellation, typically in a finally block.
async def inner(): try: await asyncio.sleep(10) finally: print("inner cleanup") async def outer(): try: await inner() finally: print("outer cleanup") async def main(): task = asyncio.create_task(outer()) await asyncio.sleep(0.1) task.cancel() try: await task except asyncio.CancelledError: pass asyncio.run(main())
Both inner and outer execute their finally blocks before the CancelledError reaches the awaiting code. This gives you a predictable place to release resources, close connections, or undo partial work.
Handling Cancellation Gracefully
A common mistake is to swallow CancelledError without re-raising it. If you catch it and return normally, the task is no longer considered cancelled, and the cancellation is lost. This can break cooperative cancellation and leave the program in an inconsistent state.
async def bad_cleanup(): try: await asyncio.sleep(10) except asyncio.CancelledError: print("cancelled, but not re-raised") # No re-raise: cancellation is swallowed async def good_cleanup(): try: await asyncio.sleep(10) except asyncio.CancelledError: print("cleaning up") raise # re-raise to preserve cancellation
Use try/finally for cleanup that must run whether the coroutine finishes normally or is cancelled. Use except CancelledError only when you need to perform special cleanup before re-raising. Always re-raise unless you have a specific reason to suppress cancellation, which is rare.
Cancellation and Timeouts
Timeouts are a common reason to cancel tasks. asyncio provides asyncio.timeout() (Python 3.11+) and the older asyncio.wait_for(). Both cancel the underlying task when the deadline passes.
async def slow_operation(): await asyncio.sleep(60) async def main(): try: async with asyncio.timeout(2): await slow_operation() except TimeoutError: print("operation timed out")
When a timeout occurs, the coroutine is cancelled internally. The CancelledError is converted into a TimeoutError for the caller. If the coroutine handles cancellation poorly, the timeout may not work as expected. For example, if the coroutine catches CancelledError and returns without re-raising, the timeout will not fire and the operation will continue.
Shielding Tasks from Cancellation
Sometimes you want a coroutine to continue even if the surrounding task is cancelled. asyncio.shield() protects a coroutine from cancellation while still allowing the outer task to be cancelled.
async def critical_operation(): await asyncio.sleep(5) return "done" async def main(): task = asyncio.create_task(critical_operation()) shielded = asyncio.shield(task) await asyncio.sleep(0.1) shielded.cancel() # cancels the shield, not the underlying task try: await shielded except asyncio.CancelledError: print("shield cancelled, but task continues") await task # wait for the actual task to finish
shield() does not stop the underlying task from being cancelled if the task itself is cancelled directly. It only protects against cancellation of the coroutine that awaits the shield. Use it sparingly, because it can make control flow harder to reason about.
Cancellation in Structured Concurrency
Python 3.11 introduced asyncio.TaskGroup, which provides structured concurrency. When any task in a group fails or is cancelled, the group cancels all other tasks and waits for them to finish. This makes cancellation predictable and scoped.
async def worker(name, delay): try: await asyncio.sleep(delay) print(f"{name} done") except asyncio.CancelledError: print(f"{name} cancelled") raise async def main(): try: async with asyncio.TaskGroup() as tg: tg.create_task(worker("a", 1)) tg.create_task(worker("b", 3)) raise ValueError("force cancellation") except* ValueError: print("group cancelled due to error") asyncio.run(main())
When an exception occurs inside the async with block, the TaskGroup cancels all pending tasks. Each task receives a CancelledError and can clean up. The group then re-raises the original exception. This pattern reduces the chance of orphaned tasks and makes cancellation boundaries explicit.
Common Pitfalls and Best Practices
Cancellation in asyncio is cooperative, not preemptive. A coroutine that never awaits cannot be cancelled. Long-running CPU-bound code inside a coroutine will block the event loop and ignore cancellation until it yields control.
Another pitfall is ignoring cancellation in cleanup code. If a finally block contains an await that itself gets cancelled, the cleanup may be interrupted. Use asyncio.shield() for critical cleanup if necessary, or keep cleanup synchronous when possible.
When you catch CancelledError, be careful not to accidentally catch it in a broad except Exception block. CancelledError inherits from BaseException in Python 3.8+, so a bare except Exception will not catch it. This is intentional: cancellation should not be treated as a regular error.
Finally, always await a cancelled task to retrieve the cancellation status. If you call task.cancel() and never await the task, the cancellation may be logged as an unhandled exception. Awaiting the task after cancellation suppresses the CancelledError and allows the task to be garbage-collected cleanly.