Ctrl-C is a signal¶
Level: 201 · for Python programmers
One line: KeyboardInterrupt is not what Ctrl-C does — it is what Python's default SIGINT handler does with it, and signal.signal() replaces that handler, so four lines of code are enough to make Ctrl-C print a message and keep counting.
Ctrl-C feels like a language feature. It is not one. Pressing it sends SIGINT to the foreground process, and what happens next is decided entirely by a function Python installed before your first line ran:
That function raises KeyboardInterrupt. It is an ordinary callable — you can call it yourself and watch the exception come out — and signal.signal() will put anything else in its place. Replace it, and the exception you have been catching for years simply stops happening:
import signal, time
def handler(signum, frame):
print("\nSIGINT received; still here")
signal.signal(signal.SIGINT, handler)
n = 0
while True:
time.sleep(0.1)
print("\r{}".format(n), end="")
n += 1
Run that and press Ctrl-C: the counter blinks, the message prints, and the loop carries straight on. Nothing was raised, so nothing unwound — a signal handler that returns normally resumes the line it interrupted. To get out, press Ctrl-\ instead, which is a different key sending a different signal that you have not taken over. (There is a way to write a program that nothing can get out of, and there are exactly two signals that still work on it: the signals you cannot catch ↗ in the Linux library.)
The rest of this page is that program taken apart, plus the two details in it that are not about signals at all: the \r that makes one line count up, and a parameter name that quietly deletes a module.
The run¶
Every signal below is delivered on purpose with signal.raise_signal() — a real signal arriving at a real process, with nothing waiting for a key, so the whole thing is recordable:
Verified output of ctrl_c_is_a_signal_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. Before you touch anything, SIGINT already has a handler
--------------------------------------------------------------
signal.getsignal(signal.SIGINT) ......... <built-in function default_int_handler>
...is signal.default_int_handler ........ True
SIGINT's number ......................... 2
It is an ordinary callable, so we can just call it ourselves:
it raised ........................... KeyboardInterrupt
So KeyboardInterrupt is not what Ctrl-C does. It is what THIS function
does, and this function is replaceable.
2. Replacing it
--------------------------------------------------------------
signal.signal() returns the OLD handler . <built-in function default_int_handler>
...which is the one from section 1 ...... True
keep it, and you can put it back.
3. A real SIGINT, delivered to ourselves
--------------------------------------------------------------
signal.raise_signal(signal.SIGINT) is what the terminal does when you
press Ctrl-C, minus the terminal:
I got a SIGINT, but I am not stopping
...and the next line still runs. Nothing was raised, nothing unwound.
A handler that returns normally resumes the code it interrupted, which
is why a `while True:` loop keeps counting.
4. What the handler is handed, and the name that shadows a module
--------------------------------------------------------------
arg 1, signum ........................... 2 (SIGINT)
arg 2, a frame object ................... frame
it was executing ................ <module>
The second parameter is a FRAME: the line that was running when the
signal arrived. Name it `time` and the module is gone inside the handler:
time.sleep(0) inside it ............. AttributeError
The loop outside keeps working, because its `time` is the global one.
The trap is local to the handler, and only springs if the handler ever
needs the module -- to time a shutdown, say.
5. A signal that arrives in the middle of time.sleep()
--------------------------------------------------------------
an alarm was set for 0.05s into a 0.25s sleep
the handler ran ......................... True
time.sleep() raised ..................... False
the sleep still lasted its full 0.25s ... True
PEP 475 (Python 3.5): a syscall interrupted by a signal is RETRIED
with the time that was left, once the handler returns. Before 3.5 the
sleep returned early and you had to loop around it yourself.
6. Putting the default back
--------------------------------------------------------------
signal.signal(SIGINT, signal.default_int_handler)
the same raise_signal now ............... KeyboardInterrupt
Three things can go in that second argument:
a function of yours ................. Python calls it
signal.SIG_DFL ...................... the kernel's default: die
signal.SIG_IGN ...................... thrown away, not delivered
SIG_DFL for SIGINT kills the process without a traceback and without
running `finally:` blocks. default_int_handler is what gives you the
traceback -- it is Python's politeness, not the kernel's.
7. The counter that overwrites itself: "\r" and when it reaches the screen
--------------------------------------------------------------
print("\r{}".format(i), end="") rewinds to the left margin and prints
over what is already there, so one line counts up instead of a thousand
lines scrolling. Whether you SEE it count depends on buffering:
line_buffering=True (a terminal)
after writing "41" .................. b''
after writing "\r" .................. b'41\r'
line_buffering=False (a pipe)
after writing "41" .................. b''
after writing "\r" .................. b''
A line-buffered stream flushes on \r as well as on \n -- which is the
only reason that counter animates without flush=True. Down a pipe there
is no line buffering at all and the whole count arrives at exit.
This run's own stdout:
sys.stdout.isatty() ................. False
sys.stdout.line_buffering ........... False
(the recorded key is captured, so both read False;
run this in a terminal and both flip to True)
8. Two rules about threads
--------------------------------------------------------------
signal.signal() from another thread ..... ValueError
a SIGINT raised by the worker ran in ..... MainThread
Only the main thread may install a handler, and every handler runs in
the main thread -- whichever thread the kernel happened to deliver to.
A worker blocked on a socket does not get interrupted; the main thread
gets the callback, and has to tell the worker itself.
What the run shows¶
Section 1 — the handler was already there. signal.getsignal(signal.SIGINT) answers before your code has installed anything, and the answer is signal.default_int_handler. Calling it by hand raises KeyboardInterrupt, which is the whole of the relationship between the key and the exception: one function, installed by the interpreter at startup, that you are free to remove. except KeyboardInterrupt: is therefore not "catch Ctrl-C" — it is "catch what the default handler makes of Ctrl-C", and those are the same sentence only for as long as nobody calls signal.signal().
Section 2 — signal.signal() returns the old handler. Keep the return value and you can put the previous behaviour back, which matters in a library: taking over SIGINT process-wide is a decision that belongs to the program, not to a module somebody imported.
Section 3 — the loop survives. The handler printed, returned, and execution continued at the next line. That is the difference between a signal and an exception in one sentence: an exception unwinds the stack looking for a handler, a signal calls a handler and then puts you back where you were.
Section 4 — the second parameter is a frame, and naming it time is a trap. A handler is called as handler(signum, frame), where frame is the stack frame that was executing when the signal landed — frame.f_code.co_name says which function was interrupted, which makes it genuinely useful for a diagnostic. Name that parameter time and inside the handler the name time is a frame object, so time.sleep(...) raises AttributeError. The loop outside is unaffected, because it resolves time as a global; only the handler is blinded, and only when it reaches for the module. It is the quietest kind of bug — correct today, broken on the day somebody adds a grace period to the shutdown path.
Section 5 — a signal that lands mid-sleep() does not shorten it. The alarm fired 0.05s into a 0.25s sleep, the handler ran, time.sleep() did not raise, and the sleep still lasted its full quarter second. Since PEP 475 ↗ (Python 3.5) a syscall interrupted by a signal is retried with the time that was left over, once the handler returns. Before 3.5 it came back early with EINTR and every loop that mattered had to be written twice.
Section 6 — three things can go in that second argument. A callable of yours, signal.SIG_DFL (the kernel's default action — for SIGINT, die), or signal.SIG_IGN (thrown away before Python ever hears about it). SIG_DFL is worth knowing for what it does not do: no traceback, no finally: blocks, no atexit. The traceback you get from Ctrl-C is Python's politeness, and it is the default handler's doing.
Section 7 — \r, and why the counter animates without flush=True. print("\r{}".format(n), end="") returns the cursor to the left margin and prints over what is there, so one line counts up instead of a thousand lines scrolling. Whether you watch it count is a buffering question, and the answer is a CPython detail worth carrying: a line-buffered stream flushes on \r as well as on \n. The run shows it directly — an io.TextIOWrapper with line_buffering=True holds "41" and lets go of it the instant a \r arrives; the same writes to a block-buffered wrapper reach the buffer underneath at neither point. That is the whole reason the idiom works bare. Send the same program down a pipe and there is no line buffering at all: the count arrives in one block at exit, and then you need flush=True. The last two lines of the section print this run's own isatty() and line_buffering, which read False in the recorded key because the runner captures output — run the file in a terminal and both flip to True.
Section 8 — the main thread owns signals, twice over. signal.signal() from any other thread is a ValueError, and a signal raised by a worker thread still runs its Python handler in the main thread. Both follow from the same design: the C-level handler only sets a flag, and the main thread notices it between bytecodes. The practical consequence is the one that bites — a worker blocked on a socket read is not interrupted by Ctrl-C. The main thread gets the callback and has to tell the worker itself, through an Event or a queue.
If you are coming from C¶
This is the API you already know, with two edges filed off. signal(2) in C hands your handler the signal number and nothing else, and what you may legally do inside it is a short list of async-signal-safe functions — printf is not on it. Python's handler is not the C handler: CPython's C handler writes a flag and returns, and your function is called later, from the main thread, at a bytecode boundary, where the whole language is available and print is fine. You pay for that with latency and with one rule C does not have — a handler cannot run while the interpreter is inside a long C call that does not check for signals.
sigaction(2) against signal(2) does not arise: Python always uses the sigaction semantics (the handler stays installed after it runs), so the historical Unix trap of a one-shot handler is not there.
If you are coming from Rust¶
Rust's standard library has no signal API at all ↗ — no std::signal, nothing in std::process that installs a handler. Ctrl-C therefore does what the kernel says by default: the process dies, no unwinding, no destructors. Getting Python's behaviour means either declaring signal from libc yourself across the C ABI, or taking a crate. The contrast is the same one as everywhere else between these two languages: Python ships a batteries-included wrapper with a global, process-wide side effect, and Rust makes you say out loud that you are reaching outside the language.
One further difference falls out of that, and it is the reason section 8 above matters less in Rust than it sounds. A Rust handler is the real C handler — it runs at an arbitrary instruction, on whichever thread the kernel picked — so it may not print, allocate or take a lock, and the idiom is an AtomicBool the main loop polls. Everything Python lets you do inside the handler, Rust makes you ask for by leaving a note.
If you are coming from ABAP¶
There is no equivalent, and that is the useful sentence. An ABAP report runs inside a work process on an application server; it has no controlling terminal, nobody presses Ctrl-C at it, and the interruptions it does get are of a different shape — the runtime's own timeout (TIME_OUT after rdisp/max_wp_runtime), a user pressing Cancel in the GUI, or an administrator ending the session in SM50. None of those are deliverable to a point in your code that you choose; you cannot install a handler for them and carry on. The transferable half is the discipline, not the API: work that must survive an interruption is committed in units small enough to resume, which is the same reason a Python service handles SIGTERM by finishing the current item rather than by trying to finish everything.
(Not machine-checked — CI cannot run ABAP.)
Try it¶
- Find out what your own long-running scripts do with Ctrl-C.
grep -rn "KeyboardInterrupt" .across a project you maintain, and for each hit ask whether the code below it assumes the exception arrives at that exact line. It arrives wherever the interpreter happened to be. - Take the loop above, add a
finally:that prints something, and compare three endings: Ctrl-C with the default handler, Ctrl-C with the handler installed, andsignal.signal(signal.SIGINT, signal.SIG_DFL)followed by Ctrl-C. Only two of the three run yourfinally:. - Run a script of yours that prints progress, once in a terminal and once piped into
cat. If the progress disappears down the pipe, you have found the buffering in section 7 in your own code;flush=Trueorpython3 -uis the fix. - Install a SIGTERM handler instead —
signal.SIGTERM— and send it withkill <pid>from another terminal. That is the signal a container runtime or an init system actually sends you before it gives up and sends the one you cannot catch. - Start a thread that loops forever, then press Ctrl-C. Note which thread the traceback names, and how long the program takes to actually exit.
See also¶
- Standard in, standard out, and pipes — the other half of section 7: what changes about
sys.stdoutwhen the far end is a pipe rather than a terminal, including theBrokenPipeErrorthat SIGPIPE turns into here. - Opening a file — buffering as
open()'s own parameter, and why whether stdout is a terminal decides the order yourprint()calls come out in. - What ends a line —
\ras a line terminator, which is the same byte doing a completely different job. -cis not the prompt — the other page here about handing a program to the interpreter and what the shell does before Python sees it.- The signals you cannot catch ↗ in the Linux library — the other side of the terminal:
trap, the two signals no process can catch, exit status 128 + N, and what Ctrl-\ and Ctrl-Z actually send. - Catching a signal ↗ in the Rust library — the same program with no standard-library help at all, and a handler that may not print.
- Signals ↗ in the concurrency library — signals as inter-process communication, and the thread-selection problem behind section 8.
signal— set handlers for asynchronous events ↗ — the module. The "General rules" section at the top is short and is the part people skip.- PEP 475 ↗ — retrying syscalls interrupted by a signal.
- Prompted by the signals and job-control section of MIT's Missing Semester ↗ lecture on the command-line environment; the code and prose here are written from scratch. See RESOURCES.
- A bare
rescuecatchesStandardError, not everything ↗ — the Ruby side:rescuewith no class list meansrescue StandardError, andSystemExit,Interrupt,NoMemoryError,… Timeout.timeoutinterrupts a thread from outside ↗ — the Ruby side:Timeout.timeout(0.2) { sleep 5 }raisesTimeout::Errorafter a fifth of a second because a watcher thread calls…exitraisesSystemExit↗ — the Ruby side:exitis not a system call but araiseofSystemExit, soensureclauses andat_exithandlers run, a `rescue…