Input & UX

Handling Keyboard Interrupt Cleanly

Make Ctrl-C behave in a Python CLI - exit 130 without a traceback, run cleanup through context managers, and never report a cancelled run as success.

Updated

Ctrl-C is the most common way a long-running command ends. The default Python behaviour — a KeyboardInterrupt traceback pointing at whatever line happened to be executing — makes a deliberate cancellation look like a crash, and an exit code of 0 or 1 tells the calling script the wrong story.

TL;DR

  • Catch KeyboardInterrupt at the boundary and exit 130 (128 + SIGINT).
  • Print nothing more than a short "cancelled" on stderr — no traceback.
  • Put cleanup in context managers and finally blocks, not in a signal handler.
  • Write output atomically so an interrupted run leaves no half-written file.
  • Terminate child processes you started, or they outlive the parent.

Prerequisites

The single main() boundary from error handling and exit codes, and a command that runs long enough for someone to interrupt it.

What Ctrl-C actually does

What Ctrl-C does to your process Pressing Ctrl-C sends SIGINT, which Python raises as KeyboardInterrupt at the boundary, where cleanup runs before the process exits with status 130. What Ctrl-C does to your process User Terminal Your code Cleanup Ctrl-C SIGINT → KeyboardInterrupt finally blocks run exit 130, no traceback The exception is raised wherever execution happens to be, which is why cleanup belongs in context managers.

The terminal sends SIGINT to the foreground process group. Python's default handler raises KeyboardInterrupt at the next opportunity — which is wherever execution happens to be, including inside a loop, a with block, or a library call.

That has two consequences worth internalising. Because it is an exception, your finally blocks and context managers run, which is why cleanup belongs there. And because it can be raised almost anywhere, no single line of your code can assume it will complete.

The boundary handler

import sys
import typer

def main() -> None:
    try:
        app()
    except KeyboardInterrupt:
        typer.secho("cancelled", fg=typer.colors.YELLOW, err=True)
        sys.exit(130)
What to return after an interrupt A decision diagram: exit with status 130 after a user interrupt, and never exit zero, which would report a cancelled run as successful. What to return after an interrupt The user pressed Ctrl-C. What status? Cancelled — the work did not finish 130 128 + SIGINT Anything else Wrong 0 claims success, 1 hides the cause A cancelled run reported as success is how a wrapper script proceeds to the next step regardless.

130 is 128 + SIGINT, the value a shell reports when a child is killed by that signal. It is what set -e scripts and CI systems expect, and it is distinguishable from both success and a genuine failure.

Exiting 0 is the worst option: a wrapper script sees success and proceeds to the next step, having skipped the work. Exiting 1 is merely unhelpful — it hides a cancellation among real errors.

Keep the message to one word on stderr. The user knows what they pressed; a paragraph explaining that the operation was cancelled is noise, and a traceback reads as a defect in your tool.

Cleaning up properly

What must be tidied on interrupt The resources a cancelled command line run should release: partial output files, locks, terminal state and child processes. What must be tidied on interrupt Partial output atomic writes Write to a temporary file and rename; an interrupted run leaves nothing half-written. Locks context manager A stale lock file blocks the next run and needs manual removal. Terminal state restore Cursor visibility and alternate screens must be restored or the shell is left broken. Child processes terminate A spawned process outliving the parent is the surprise nobody debugs quickly. all four are finally blocks, not signal handlers Everything here works because Python raises an exception rather than killing the process outright.

Four things routinely need tidying, and all four are finally blocks rather than signal handlers.

Partial output. Write to a temporary file beside the destination and rename at the end. The rename is atomic on every platform that matters, so an interrupted run leaves the old file intact rather than a truncated new one:

def write_atomic(path: Path, data: str) -> None:
    tmp = path.with_suffix(path.suffix + ".tmp")
    try:
        tmp.write_text(data, encoding="utf-8")
        tmp.replace(path)
    finally:
        tmp.unlink(missing_ok=True)

Locks. A lock file left behind blocks the next run and needs manual removal, which is a support ticket waiting to happen:

@contextmanager
def exclusive_lock(path: Path):
    lock = path.with_suffix(".lock")
    lock.touch(exist_ok=False)
    try:
        yield
    finally:
        lock.unlink(missing_ok=True)

Terminal state. If you hid the cursor or entered an alternate screen, restore it — otherwise the user's shell is left in a strange state after cancelling. Rich's Progress and Live handle this in their own __exit__, which is a good reason to use them as context managers rather than starting and stopping them by hand.

Child processes. A subprocess you started does not necessarily die with you:

proc = subprocess.Popen([...])
try:
    proc.wait()
except KeyboardInterrupt:
    proc.terminate()
    proc.wait(timeout=5)
    raise                     # let the boundary decide the exit code

Note the raise. The command handles its own resource and then lets the interrupt continue propagating, so the exit code decision stays in one place.

What not to do

Do not install a SIGINT handler that sets a flag and returns. It sounds tidy and it means Ctrl-C no longer interrupts a blocking call — a user pressing it during a network read waits for the timeout, presses it again, and gets a much less graceful exit. The exception-based default is better than almost anything you would write.

Do not swallow it. except KeyboardInterrupt: pass inside a loop makes a command impossible to stop, which is genuinely infuriating. If you catch it locally to clean up, re-raise.

Do not treat a second Ctrl-C as a failure of the first. Users press it twice when the first appeared to do nothing. If your cleanup is slow, say so — cancelling… on stderr — so the second press is informed rather than reflexive.

UX considerations

Say what was not done. For a command with partial results, one line is worth printing: cancelled after 412 of 1,000 files; nothing was uploaded. It tells the user whether they need to undo anything.

Make cancellation safe by design. A command that can be interrupted at any point without leaving inconsistent state is better than one with elaborate cleanup. Atomic writes and idempotent operations get you most of the way there.

Consider a resume hint. If the operation supports resuming, the cancellation message is the right place to mention it: re-run with --resume to continue.

Interrupting work that is already partly done

For a command that has completed part of its work, the useful question is what the user should be able to do next. Three arrangements, in increasing order of effort.

Idempotent by design. If re-running the command simply skips what is already done, cancellation needs no special handling at all — the user runs it again. A sync that compares checksums is naturally in this category, and it is worth aiming for.

Report the boundary. When the work is not idempotent, say exactly where it stopped, so a human can decide:

except KeyboardInterrupt:
    err.print(f"[yellow]cancelled after {done} of {total} files[/]")
    err.print("[dim]uploaded files are complete; re-run to continue[/]")
    raise

Record progress. For genuinely long operations, write a small state file as you go and support --resume. It is real work — the state file needs the same atomic-write treatment as your output — so reserve it for commands where restarting is expensive rather than merely annoying.

What ties all three together is that the decision belongs to the user. A tool that silently rolls back an hour of completed work because someone pressed Ctrl-C is as unhelpful as one that leaves no indication of where it stopped.

Testing the behaviour

The in-process cases are easy — raise the exception where the work happens:

def test_interrupt_exits_130(cli, monkeypatch):
    def boom(*args, **kwargs):
        raise KeyboardInterrupt

    monkeypatch.setattr("mytool.commands.sync.core.sync_directory", boom)

    result = cli.invoke(app, ["sync", "data"], catch_exceptions=True)

    assert result.exit_code == 130
    assert "cancelled" in result.stderr
    assert "Traceback" not in result.stderr

def test_lock_is_released_on_interrupt(tmp_path):
    with pytest.raises(KeyboardInterrupt):
        with exclusive_lock(tmp_path / "state.json"):
            raise KeyboardInterrupt

    assert not (tmp_path / "state.lock").exists()

For the real signal path, one subprocess test proves the whole chain:

import os, signal, subprocess, sys, time

def test_real_sigint_is_handled():
    proc = subprocess.Popen(
        [sys.executable, "-m", "mytool", "sync", "./big"],
        stderr=subprocess.PIPE,
    )
    time.sleep(0.5)
    proc.send_signal(signal.SIGINT)
    _, stderr = proc.communicate(timeout=10)

    assert proc.returncode == 130
    assert b"Traceback" not in stderr

That test is worth the subprocess: it is the only way to verify that the signal, the exception, the cleanup and the exit code all line up in the real process.

Conclusion

Catch the interrupt once at the boundary, exit 130, print one word, and let context managers do the cleanup. Avoid signal handlers, avoid swallowing it, and write output atomically so an interrupted run is simply a run that did not finish — rather than one that left a mess.

Frequently asked questions

Why 130 rather than 1 or 0?

Because it is what the shell reports for a process killed by SIGINT, so wrapper scripts and CI systems already understand it. Zero is actively harmful — a cancelled run reported as success means the next step in a pipeline proceeds as though the work happened.

Does Click or Typer handle this already?

Partially. Click converts KeyboardInterrupt into an Abort and exits with code 1, which is better than a traceback and still not the conventional value. Adding the boundary handler gives you 130 and control of the message.

What about SIGTERM?

Worth handling for anything that might run under a supervisor or in a container, where SIGTERM is how shutdown is requested. Install a handler that raises KeyboardInterrupt (or a custom exception) so the same cleanup path runs, and exit 143 — 128 + SIGTERM.

Will my finally blocks always run?

For KeyboardInterrupt, yes, because it is an ordinary exception. They do not run if the process is killed by SIGKILL or by restoring the default SIGPIPE handler, which is one of the arguments against that approach in handling broken pipe.

How do I stop a long loop promptly?

You usually do not need to — the exception is raised at the next bytecode boundary, which is immediate in practice. Where it feels slow is a blocking call with a long timeout, and the fix is a shorter timeout with retries rather than a signal handler.