Skip to content

Cancelling All Tasks on KeyboardInterrupt

Pressing Ctrl-C in an asyncio program does not raise KeyboardInterrupt at a random line any more. Since Python 3.11, asyncio.run installs its own SIGINT handler: the first Ctrl-C cancels the main task, everything unwinds as a normal cancellation, and asyncio.run raises KeyboardInterrupt to its caller at the end. Traced on Python 3.14 with a main coroutine and two background tasks, each with 0.2–0.3 s of cleanup: on Ctrl-C, main() received CancelledError first and finished its cleanup; only after main() returned were the background tasks cancelled, and their cleanups ran concurrently; then asyncio.run raised KeyboardInterrupt and the script exited normally. A second Ctrl-C 0.1 s after the first cut main()'s cleanup short, though the background tasks' cleanup still ran. This guide builds a script and service shutdown on that sequence, so every task gets its turn to clean up and a second Ctrl-C still means "stop now".

Prerequisites

1. See the order asyncio.run follows

The first Ctrl-C becomes a cancellation of the main task. Other tasks are left alone until the main task has finished:

async def background(n: int) -> None:
    try:
        await asyncio.sleep(100)
    except asyncio.CancelledError:
        await asyncio.sleep(0.2)              # cleanup
        raise


async def main() -> None:
    tasks = [asyncio.create_task(background(i)) for i in range(2)]
    try:
        await asyncio.sleep(100)
    finally:
        await asyncio.sleep(0.3)              # main's own cleanup


try:
    asyncio.run(main())
except KeyboardInterrupt:
    print("interrupted")

Traced timestamps after Ctrl-C at 0.96 s: main got CancelledError at 0.96 s and finished cleanup at 1.26 s; both background tasks got CancelledError at 1.26 s and finished cleanup at 1.47 s; asyncio.run raised KeyboardInterrupt at 1.47 s. The background tasks are cancelled by asyncio.run's own shutdown step, which cancels every remaining task and waits for them. The process exited with code 0 because the script caught KeyboardInterrupt; without the try, Python prints a traceback and exits with code 130.

Verify: add timestamps to each cleanup path in your script; the order matches: main first, then all remaining tasks together.

What happens after Ctrl-C under asyncio.run 3 lanes over time. What happens after Ctrl-C under asyncio.run main() running cleanup 0.3 s done background tasks still running cleanup done asyncio.run running loop cancel main, then rest raises KbdInt Ctrl-C where main's cleanup begins → Traced on Python 3.14: main cleaned up 0.96-1.26 s, the rest 1.26-1.47 s.

2. Cancel your own tasks from main, in the order you need

Relying on asyncio.run to cancel leftovers works, but it cancels everything at once, in no particular order, after main is done. When shutdown order matters — stop accepting work, drain workers, then close connections — do it in main's finally:

async def main() -> None:
    pool = await create_pool()
    workers = [asyncio.create_task(worker(pool), name=f"worker-{i}") for i in range(4)]
    producer = asyncio.create_task(produce(), name="producer")
    try:
        await asyncio.gather(producer, *workers)
    finally:
        producer.cancel("shutdown: stop producing")           # 1. no new work
        await asyncio.gather(producer, return_exceptions=True)
        for w in workers:                                     # 2. stop workers
            w.cancel("shutdown: stop workers")
        await asyncio.gather(*workers, return_exceptions=True)
        await pool.close()                                    # 3. resources last

Cancelling and awaiting in stages makes the order explicit, and return_exceptions=True keeps one task's error from skipping the rest of the shutdown. The messages make the stop reasons visible in logs, as in cancelling tasks with a message. A TaskGroup gives the same structure automatically: when main is cancelled, the group cancels its children and waits for them before main continues unwinding.

Verify: Ctrl-C during a run produces log lines in the intended order, and the connection pool closes last.

3. Treat a second Ctrl-C as "stop now"

Users press Ctrl-C again when shutdown seems stuck. Under asyncio.run, the second press during the main task's cleanup interrupts that cleanup:

# traced: second Ctrl-C 0.1 s after the first
#   main: CancelledError at 0.95 s
#   main: finally at 1.06 s           <- cleanup cut short; "cleanup done" never printed
#   background tasks: cancelled at 1.06 s, cleanup done at 1.26 s
#   asyncio.run raised KeyboardInterrupt

The background tasks' cleanup still ran, because it happens in asyncio.run's shutdown phase after main has gone. That is reasonable default behaviour: the first press asks politely, the second insists. Make sure the cleanup that must not be interrupted — finishing a file write, committing a transaction already in progress — is short and shielded, and that everything else tolerates being cut off:

async def main() -> None:
    try:
        await run_job()
    finally:
        await asyncio.shield(checkpoint.save())     # survives a second cancellation of main
        await close_connections()                   # may be cut short; that is acceptable

Verify: pressing Ctrl-C twice still leaves a valid checkpoint, and the process exits promptly.

One Ctrl-C versus two A grid of 4 rows by 3 columns. One Ctrl-C versus two event one Ctrl-C second Ctrl-C after 0.1 s main() cleanup completed (0.3 s) cut short background task cleanup completed completed asyncio.run result KeyboardInterrupt KeyboardInterrupt total time after first press 0.51 s 0.31 s The second press shortens main's cleanup; shield what must finish.

4. Do not catch KeyboardInterrupt inside coroutines

Under asyncio.run, the interrupt reaches coroutines as CancelledError, not KeyboardInterrupt. Code that catches KeyboardInterrupt inside async functions never runs; code that catches BaseException and continues swallows the shutdown:

async def worker():
    try:
        await process_forever()
    except KeyboardInterrupt:               # never raised here under asyncio.run
        save_progress()
    except BaseException:
        log.exception("unexpected")         # swallows CancelledError: Ctrl-C does nothing

Handle shutdown with except asyncio.CancelledError: ...; raise or finally, and catch KeyboardInterrupt only outside asyncio.run, where it arrives at the end. Older code that drives the loop manually with loop.run_until_complete gets the old behaviour — KeyboardInterrupt raised wherever the loop happened to be — and should move to asyncio.run or asyncio.Runner.

Verify: grep coroutines for except KeyboardInterrupt and except BaseException without re-raise; there should be none.

5. Use the same path for SIGTERM in services

Services are stopped with SIGTERM, not Ctrl-C, and asyncio.run does not handle SIGTERM by default — the process just dies. Route SIGTERM into the same cancellation path:

import signal


async def main() -> None:
    loop = asyncio.get_running_loop()
    main_task = asyncio.current_task()
    loop.add_signal_handler(signal.SIGTERM, main_task.cancel, "shutdown: SIGTERM")
    try:
        await serve_forever()
    finally:
        await shutdown_in_order()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass                                   # Ctrl-C during development: exit quietly

Now SIGTERM and Ctrl-C both cancel the main task and run the same ordered shutdown. add_signal_handler is available on Unix event loops; on Windows, asyncio.run still handles Ctrl-C, but other signals need different mechanisms. The service-level details — readiness, grace periods, Kubernetes — are in shutting down asyncio pods in Kubernetes.

Verify: kill -TERM <pid> produces the same ordered shutdown logs as Ctrl-C.

How should this program shut down on interrupt? A decision on What kind of program is it with 4 outcomes. How should this program shut down on interrupt? What kind of program is it? small script asyncio.run default catch KeyboardInterrupt outside ordered shutdown cancel in stages in finally or TaskGroup service SIGTERM -> main_task.cancel same path as Ctrl-C must-finish cleanup asyncio.shield survives second Ctrl-C One cancellation path, entered by Ctrl-C or SIGTERM, cleaned up in order.

Verification

Interrupt handling is correct when:

  • Ctrl-C cancels main, and main stops its tasks in order before asyncio.run returns.
  • No coroutine catches KeyboardInterrupt or swallows BaseException.
  • A second Ctrl-C exits promptly, with must-finish steps shielded.
  • SIGTERM follows the same path in services.

Diagnostic Hook: log a line at the start and end of shutdown with the elapsed time, plus one line per task as it stops. A shutdown that never logs its end is stuck in a task that ignores cancellation; tasks logging their stop only after asyncio.run's own cancellation phase are tasks main did not know about — usually fire-and-forget tasks without a registry.

Pitfalls & edge cases

  • Expecting all tasks to be cancelled at once. Traced: main first, the rest only after main returns.
  • Long unshielded cleanup in main. A second Ctrl-C cut it short.
  • except KeyboardInterrupt inside coroutines. It never fires under asyncio.run.
  • Unhandled SIGTERM. asyncio.run handles only Ctrl-C by default.

Frequently Asked Questions

What happens when I press Ctrl-C in an asyncio program?

Since Python 3.11, asyncio.run cancels the main task, waits for it to finish, cancels all remaining tasks and waits for them, then raises KeyboardInterrupt. In a traced run, main cleaned up first and background tasks were cancelled only after it returned.

How do I cancel all asyncio tasks on KeyboardInterrupt?

Let asyncio.run do it, or cancel your tasks explicitly in main's finally block in the order you need, awaiting each stage with return_exceptions=True.

Why is my KeyboardInterrupt handler inside a coroutine never called?

Under asyncio.run the interrupt reaches coroutines as CancelledError; KeyboardInterrupt is raised only by asyncio.run itself, after shutdown.

What does a second Ctrl-C do in asyncio?

It interrupts the main task's ongoing cleanup; in testing main's cleanup was cut short while background tasks still cleaned up. Shield anything that must complete.