Skip to content

Handling Ctrl-C in asyncio Scripts

Command-line tools and batch scripts get interrupted with Ctrl-C all the time, and an asyncio script's default behaviour is mostly right but rough around the edges. Tested on Python 3.14: an unhandled Ctrl-C ended a trivial script with exit code 130 and a 27-line traceback through asyncio's internals — noise for the user and alarming in logs. Catching KeyboardInterrupt around asyncio.run gave the same exit code 130 with a one-line message. One case was genuinely broken: a script waiting for a line of input through asyncio.to_thread(sys.stdin.readline) cancelled its main task at Ctrl-C but did not exit — asyncio.run waited for the blocked thread, and the process stayed until a line was entered 5 seconds later. Reading stdin through the event loop with connect_read_pipe exited in 10 ms. This guide makes scripts exit promptly, quietly and with progress saved.

Prerequisites

1. Exit with 130 and one line, not a traceback

Catch KeyboardInterrupt outside asyncio.run, print a short message, and exit with the conventional status:

import asyncio
import sys


async def main() -> int:
    ...
    return 0


if __name__ == "__main__":
    try:
        sys.exit(asyncio.run(main()))
    except KeyboardInterrupt:
        print("interrupted", file=sys.stderr)
        sys.exit(130)                  # 128 + SIGINT, what shells and CI expect

Tested: without the handler, the script printed a 27-line traceback and exited with 130; with it, one line and 130. Exit code 130 matters to callers — a shell script, a Makefile, a CI step — that distinguish "interrupted by the user" from "failed". Inside coroutines, the interrupt arrives as CancelledError, so cleanup belongs in finally blocks there, never in except KeyboardInterrupt.

Verify: python script.py; echo $? after Ctrl-C prints the short message and 130.

What Ctrl-C did to different scripts A grid of 4 rows by 3 columns. What Ctrl-C did to different scripts script on Ctrl-C exit no handling 27-line traceback 130 except KeyboardInterrupt outside asyncio.run one line 130 awaiting to_thread(sys.stdin.readline) hung until input (5 s) after input stdin via loop.connect_read_pipe exited in 10 ms 130 Blocking threads are the one thing Ctrl-C cannot interrupt.

2. Do not block on input in a thread

Reading user input with input() or sys.stdin.readline() inside asyncio.to_thread keeps the event loop free, but the thread cannot be interrupted. On Ctrl-C the main task is cancelled, yet asyncio.run then waits for the default executor's threads — including the one still blocked on stdin:

# Hangs on Ctrl-C until the user presses Enter
line = await asyncio.to_thread(sys.stdin.readline)


# Exits promptly: stdin read through the event loop (POSIX)
async def stdin_reader() -> asyncio.StreamReader:
    loop = asyncio.get_running_loop()
    reader = asyncio.StreamReader()
    await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
    return reader

reader = await stdin_reader()
line = await reader.readline()                      # cancellable like any other await

Tested: the thread version printed "main cancelled" at 0.97 s but kept running until a line arrived at 6.10 s; the pipe version exited 10 ms after Ctrl-C. connect_read_pipe works for terminals, pipes and files on Unix; on Windows, use a daemon thread (threading.Thread(daemon=True)) feeding an asyncio.Queue via loop.call_soon_threadsafe, which the interpreter does not wait for at exit. Libraries such as prompt_toolkit provide async input with editing.

Verify: press Ctrl-C at an input prompt; the script exits immediately.

3. Save progress when interrupted

Long scripts — migrations, batch imports, crawlers — should resume, not restart, after Ctrl-C. Save a checkpoint in finally, shielded so a second Ctrl-C does not cut it off:

async def main() -> int:
    state = load_checkpoint() or State(next_index=0)
    try:
        for i in range(state.next_index, len(items)):
            await process(items[i])
            state.next_index = i + 1
            if i % 100 == 0:
                await save_checkpoint(state)
    finally:
        await asyncio.shield(save_checkpoint(state))      # runs on Ctrl-C too
        print(f"stopped at item {state.next_index} of {len(items)}", file=sys.stderr)
    return 0

On Ctrl-C the loop's current await raises CancelledError, the finally block saves exactly how far the script got, and the next run starts there. Write the checkpoint atomically, as in writing files atomically from async code, so an interrupt during the save cannot corrupt it. Tell the user where it stopped; it makes Ctrl-C feel safe to use.

Verify: interrupt the script, rerun it, and processing resumes at the reported item with nothing repeated beyond the last item in progress.

A script that handles Ctrl-C well A flow of 5 stages. A script that handles Ctrl-C well Ctrl-C main task cancelled finally shielded checkpoint save child processes stopped, reaped asyncio.run KeyboardInterrupt entry point one line, exit 130 Cleanup in coroutines, messaging at the entry point.

4. Stop subprocesses the script started

A Ctrl-C in a terminal sends SIGINT to the whole foreground process group, so child processes usually receive it too — unless they were started in their own session or the script was signalled directly (by a supervisor, kill -INT, or a CI runner). Make child cleanup explicit:

async def run_tool(*cmd: str) -> int:
    proc = await asyncio.create_subprocess_exec(*cmd)
    try:
        return await proc.wait()
    except asyncio.CancelledError:
        if proc.returncode is None:
            proc.terminate()
            try:
                await asyncio.wait_for(proc.wait(), 5)
            except TimeoutError:
                proc.kill()
                await proc.wait()
        raise

Relying on the terminal's process-group signal is fragile: the same script run from a cron job or a CI step gets a signal on its own PID only. The patterns for whole process trees are in killing subprocess trees on cancellation.

Verify: kill -INT <script pid> (not the group) leaves no child processes running.

5. Make a second Ctrl-C force the exit

If cleanup is slow, users press Ctrl-C again expecting the script to stop now. asyncio.run already interrupts the main task's cleanup on a second Ctrl-C; make sure your cleanup tolerates that and that nothing ignores it:

async def main() -> int:
    try:
        await work()
    finally:
        print("cleaning up... (Ctrl-C again to force)", file=sys.stderr)
        await asyncio.shield(save_checkpoint(state))     # short, protected
        await close_connections()                        # may be cut short by a second Ctrl-C

Tell the user that a second press forces the exit, keep the protected part short, and avoid except BaseException: pass or swallowed CancelledError anywhere in the cleanup path, which would make Ctrl-C appear to do nothing. For long-running cleanup that must complete, print progress so users can see it is not stuck.

Verify: pressing Ctrl-C twice exits within a second, with the checkpoint saved.

What does this script need for clean interrupts? A decision on What does the script do with 4 outcomes. What does this script need for clean interrupts? What does the script do? anything catch KeyboardInterrupt at entry one line, exit 130 reads stdin connect_read_pipe thread hung 5 s long batch shielded checkpoint in finally resume next run starts processes terminate on CancelledError no orphans Prompt, quiet, resumable: what users expect from Ctrl-C.

Verification

A script handles Ctrl-C well when:

  • It exits with 130 and one line, not a traceback.
  • No thread blocks indefinitely, especially on stdin.
  • Progress is saved in shielded finally blocks and resumes next run.
  • Child processes are stopped, and a second Ctrl-C forces a prompt exit.

Diagnostic Hook: time from Ctrl-C to process exit, measured by wrapping the script in time in a test harness that sends SIGINT. Anything beyond the checkpoint save is waiting on something — usually an executor thread blocked on I/O, which asyncio.run waits for before returning.

Pitfalls & edge cases

  • No handler at the entry point. Tested: a 27-line traceback.
  • Blocking input in to_thread. Tested: the script waited for Enter after Ctrl-C.
  • Cleanup in except KeyboardInterrupt inside coroutines. It never runs under asyncio.run.
  • Relying on the terminal to signal children. Supervisors signal only your PID.

Frequently Asked Questions

How do I exit an asyncio script cleanly on Ctrl-C?

Wrap asyncio.run in try/except KeyboardInterrupt, print a short message and call sys.exit(130). Put cleanup in finally blocks inside your coroutines, where the interrupt arrives as CancelledError.

Why doesn't my asyncio script exit when I press Ctrl-C?

Often a thread from asyncio.to_thread is blocked, for example on input(); asyncio.run waits for executor threads at shutdown. In testing such a script ran until a line was entered. Read stdin with loop.connect_read_pipe instead.

What exit code should a script use when interrupted?

130, which is 128 plus the SIGINT signal number and what shells report for Ctrl-C.

How do I save progress when a Python asyncio script is interrupted?

Save a checkpoint in a finally block wrapped in asyncio.shield, written atomically, and start from it on the next run.