Python asyncio shield: Prevent Task Cancellation
python asyncio shield: Learn how asyncio.shield() protects coroutines from cancellation, where it falls short, and when to use it for cleanup and critical operations.
Cancelling a task in asyncio raises CancelledError inside the coroutine at the next await point, unwinding the entire await chain. Most of the time that is the behavior you want, but some operations must finish even when the surrounding task is cancelled. The python asyncio shield function, asyncio.shield(), exists for exactly that case.
How Cancellation Normally Works in asyncio
When you cancel a task in asyncio, the event loop schedules a CancelledError to be raised inside the coroutine at the next await point. That exception propagates up through the entire await chain, unwinding every coroutine that is awaiting the cancelled task.
import asyncio async def worker(): try: await asyncio.sleep(10) except asyncio.CancelledError: print("worker cancelled") raise async def main(): task = asyncio.create_task(worker()) await asyncio.sleep(0.1) task.cancel() try: await task except asyncio.CancelledError: print("main saw cancellation")
This behavior is usually what you want: a cancelled request should stop doing work. But sometimes an operation must finish even when the surrounding task is cancelled, and that is where asyncio.shield() comes in.
What asyncio.shield() Does
asyncio.shield(aw) wraps an awaitable in a task that is protected from cancellation coming from the code that awaits it. The function returns a future that mirrors the inner task's result or exception, but cancelling the outer task does not cancel the inner one.
import asyncio async def critical_operation(): await asyncio.sleep(5) return "completed" async def main(): try: await asyncio.shield(critical_operation()) except asyncio.CancelledError: print("main was cancelled, but critical_operation keeps running")
The shielded task continues in the background. If the event loop is still running, the inner coroutine eventually finishes and its result is discarded unless something else awaits it.
What shield Does Not Protect Against
The protection is one-directional. If you cancel the future returned by asyncio.shield() directly, the inner task is cancelled just like any other task.
async def main(): shielded = asyncio.shield(critical_operation()) shielded.cancel() # this cancels critical_operation too try: await shielded except asyncio.CancelledError: print("inner task was cancelled")
Shield also does not protect against event loop shutdown. When the loop closes, pending tasks are cancelled regardless of whether they were shielded. The same applies to process termination — shield only affects cancellation within a running loop.
Practical Use Case: Guaranteeing Cleanup
A common pattern is using shield to ensure that cleanup or finalization code runs even when the main task is cancelled. For example, flushing a buffer or closing a connection:
async def flush_and_close(): await buffer.flush() await connection.close() async def main(): try: await asyncio.shield(flush_and_close()) except asyncio.CancelledError: # flush_and_close is still running in the background raise
The key detail is that the cleanup coroutine must be awaited somewhere. If the main task is cancelled and nothing else awaits the shield future, the cleanup still runs, but you cannot observe its result. If the cleanup raises, the exception is lost unless you attach a done callback or await the shield from another task.
Common Pitfalls
The most frequent mistake is treating shield as a general cancellation barrier. It is not. It only protects the inner task from cancellation of the outer task. Direct cancellation of the shield, event loop shutdown, and process termination all still cancel the inner work.
Another pitfall is forgetting to keep a reference to the shield future. If you create a shield and never await it, the inner task may be garbage collected before it finishes, depending on how the event loop tracks it. In practice, always await the shield or store the future in a place that keeps it alive.
A third issue is error handling. A shielded task that raises an exception will propagate that exception when the shield future is awaited. If the outer task was cancelled and you re-raise CancelledError, the shield's exception is never observed, which can trigger "exception was never retrieved" warnings.
Alternatives to shield
If the operation is genuinely independent of the calling task, a separate task created with asyncio.create_task() is often clearer than shield. The distinction is whether you want the operation to be tied to the caller's lifetime.
| Approach | Behavior on caller cancellation |
|---|---|
await asyncio.shield(coro()) | Inner coroutine keeps running |
task = asyncio.create_task(coro()) | Task keeps running independently |
await coro() | Coroutine is cancelled |
Use shield when the operation is logically part of the caller's work but must survive cancellation. Use a separate task when the operation is independent and you want to manage its lifecycle explicitly. Shield is rarely the right tool for background work that has no relationship to the caller's cancellation state.