Python Signal Handling: SIGINT, SIGTERM, and SIGHUP
What is a signal?
Press Ctrl-C in a terminal and the foreground process gets SIGINT. Send kill 1234 and process 1234 gets SIGTERM (or whichever number you pick). The program never asked for any of this; the kernel just nudges it. Most Python programs only ever see this as a KeyboardInterrupt raised into the main thread, but the moment you write a long-running service, a CLI tool, or anything that needs to shut down cleanly, you need to take control of the signal handling yourself. This guide covers Python signal handling end to end — what the signal module offers, the rules that catch people out, and the patterns that survive real workloads.
TL;DR
- Install a handler with
signal.signal(sig, handler). The handler runs on the main thread, between bytecode instructions, and gets(signum, frame)as arguments. - For graceful shutdown, install handlers for both
SIGINT(Ctrl-C) andSIGTERM(process managers like systemd, Docker, Kubernetes) and have the main loop watch athreading.Event. - Keep handlers tiny and async-signal-safe. Set a flag, write to a pipe, never grab a
threading.Lockfrom inside one. - For timeouts,
signal.alarm(seconds)covers the simple case;signal.setitimercovers multiple concurrent timers and float second values. - Do not catch synchronous faults (
SIGSEGV,SIGFPE,SIGBUS,SIGILL); use thefaulthandlermodule instead. - For sending signals,
os.kill(pid, sig)works cross-platform;signal.raise_signal(sig)is the modern way to send to your own process. - On Windows,
CTRL_C_EVENTandCTRL_BREAK_EVENTare accepted only byos.kill(), not bysignal.signal().
Before diving in, a quick way to see what your platform supports:
>>> import signal
>>> sorted(signal.valid_signals()) # added in 3.8
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...]
>>> signal.getsignal(signal.SIGINT)
<built-in function default_int_handler>
The first call lists every signal number the current platform knows about, and the second shows the handler that is installed by default for SIGINT (the function that raises KeyboardInterrupt). Run those two lines in a REPL to get a feel for what you are working with before you start installing custom handlers.
How do I install a signal handler with signal.signal()?
The core call is signal.signal(signalnum, handler). The handler argument is either a callable taking (signum, frame), or one of two special constants: signal.SIG_DFL (restore the OS default) and signal.SIG_IGN (drop the signal silently).
The handler signature
signum is the integer signal number that arrived. frame is the stack frame at the point the signal interrupted your program, useful for debugging but rarely needed in production code.
import signal, sys
def handler(signum, frame):
print(f"got signal {signum}", file=sys.stderr)
raise SystemExit(0)
signal.signal(signal.SIGINT, handler)
while True:
pass
After the handler is installed, the program sits in the busy while True: pass loop. Press Ctrl-C in the terminal and the interpreter will run handler on the next bytecode instruction, then exit via SystemExit(0). The signum 2 is SIGINT on Linux and macOS, so the program prints the one line below before the process terminates cleanly:
# output (after pressing Ctrl-C)
got signal 2
signal.signal() returns the handler that was previously installed, which lets you save and restore handlers if you need a temporary override inside a library or a context manager.
Restoring the default
Pass signal.SIG_DFL to put a signal back to whatever the OS would normally do. A common pattern is “first Ctrl-C is polite, second one kills”:
import signal
def first(signum, frame):
print("press Ctrl-C again to force exit")
signal.signal(signal.SIGINT, signal.SIG_DFL)
signal.signal(signal.SIGINT, first)
while True:
pass
This is the same trick that long-running CLI tools use so a stuck shutdown can be forced with a second press.
What signals will I actually meet?
The signal module exposes more than twenty signal constants on a typical Linux box, but most real code only touches a handful of them. The rest are platform-specific faults (SIGSTKFLT, SIGPWR), archaic job-control signals from the BSD terminal era, and a couple of constants that exist for compile-time compatibility. The five that show up in production programs are SIGINT, SIGTERM, SIGHUP, SIGUSR1, and SIGUSR2, plus the two uncatchable ones (SIGKILL and SIGSTOP) that round out the set.
You can see the numeric values on your platform in a REPL:
>>> import signal
>>> signal.SIGINT, signal.SIGTERM, signal.SIGHUP
(<Signals.SIGINT: 2>, <Signals.SIGTERM: 15>, <Signals.SIGHUP: 1>)
The integers are the same on Linux and macOS, but you should never hardcode them — always go by the symbolic name, since Windows assigns different numbers and some niche signals are Linux-only.
SIGINT: Ctrl-C
SIGINT (signal 2 on Linux and macOS) is what terminals send when you press Ctrl-C. Python’s default handler raises KeyboardInterrupt in the main thread. You can either let that happen and wrap your top-level work in try: ... except KeyboardInterrupt:, or install your own handler and decide what “shutdown” means for your program.
SIGTERM: the polite kill
SIGTERM (signal 15) is what systemctl stop, Docker, Kubernetes, and most process managers send to ask a program to shut down gracefully. If you want clean shutdowns in production, install a SIGTERM handler. The default action of SIGTERM is to terminate the process, the same as SIGINT, but process managers assume your code will catch it and do useful work before exiting.
SIGHUP: log reopen and terminal hangup
SIGHUP historically meant “the modem hung up” or “the controlling terminal closed”. Daemons co-opted it to mean “reopen your log files” so a log rotation can complete without losing entries. If you write a long-running service, it is a reasonable signal to wire to a config-reload handler, even if your program is not a traditional daemon.
SIGUSR1 and SIGUSR2: your own protocols
SIGUSR1 and SIGUSR2 are reserved for whatever you want. A worker process can use SIGUSR1 for “dump debug stats to stderr” and SIGUSR2 for “reload configuration”. The receiver has to know what the sender intends, so it is a private protocol you define for your application. This is also where the os module becomes useful: os.kill(pid, signal.SIGUSR1) sends the signal to a specific child process.
Signals you cannot catch
SIGKILL (9) and SIGSTOP (19) cannot be caught, blocked, or ignored. This is a kernel rule, not a Python rule. If your process refuses to die on a polite SIGTERM, the only escalation is SIGKILL, and there is no Python code you can write to prevent it.
Why do signal handlers always run on the main thread?
The C-level signal handler sets a flag; the CPython interpreter checks that flag on each bytecode instruction and dispatches your Python handler on the main thread. Two consequences follow from this:
- Handlers only run on the main thread. Signals are not a way to talk to worker threads. Use a
threading.Event, aqueue.Queue, orconcurrent.futuresfor that. - Only the main thread of the main interpreter may install handlers. Calling
signal.signal()from a worker thread raisesValueError, which you can see for yourself:
import signal, threading
caught = None
def bad():
global caught
try:
signal.signal(signal.SIGUSR1, lambda *a: None)
except ValueError as e:
caught = e
t = threading.Thread(target=bad, daemon=True)
t.start()
t.join()
print(caught)
The worker catches ValueError locally and stashes it in the module-level caught variable, so the main thread can print the message after t.join() returns. This is the shortest reproduction of the main-thread rule you can run locally, and it is worth keeping in your notes: any future code that tries to wire up signals from inside a ThreadPoolExecutor worker will hit exactly the same wall.
# output
signal only works in main thread of the main interpreter
A third consequence is more subtle. If your main thread is stuck inside a long C extension call (a large re match, a NumPy reduction, a socket.recv() on a quiet connection), the signal will not fire until control returns to a Python bytecode instruction. That can be a long time, and it is why people think “my handler is broken” when in fact it has not had a chance to run yet.
How do I shut down gracefully on SIGINT or SIGTERM?
The idiomatic way to handle SIGINT and SIGTERM in a long-running program is to set a flag inside the handler and let the main loop notice on its own terms:
import signal, threading, time
stop = threading.Event()
def request_stop(signum, frame):
print(f"received signal {signum}, shutting down")
stop.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
while not stop.is_set():
print("working…")
stop.wait(1.0) # returns True when set, else times out
print("clean exit")
In this run the process got SIGTERM (signum 15), the same code path as Ctrl-C, just from a different source. The handler prints once and sets the flag, and the main loop notices on its next wait() call. The stop.wait(1.0) timeout is the trick: it returns True when the event is set, or False after a second of waiting, so the loop never wedges if no signal arrives. The “working…” lines print once per second until the signal lands, then the loop exits. Running it again with Ctrl-C gives signum 2 instead:
# output
working…
working…
received signal 15, shutting down
clean exit
threading.Event is async-signal-safe. The handler does nothing except flip the flag, and the worker reacts in its own time. Compare this to doing real work inside the handler: the docs are explicit that handlers can deadlock if they touch a threading.Lock they do not already own, and exceptions raised in a handler are injected “out of thin air” into the main thread, which is a fine way to corrupt program state.
If you want to know which signal triggered the shutdown, store the signum from the handler:
last_signal = {"value": None}
def remember(signum, frame):
last_signal["value"] = signum
stop.set()
How do I add a timeout with signal.alarm() or setitimer()?
signal.alarm(seconds) schedules SIGALRM to fire after seconds seconds. Only one alarm can be pending at a time, so a new call cancels the previous one and returns the seconds left on it. signal.alarm(0) cancels any pending alarm.
import signal
def timeout(signum, frame):
raise TimeoutError("took too long")
signal.signal(signal.SIGALRM, timeout)
signal.alarm(5)
try:
long_running_call()
finally:
signal.alarm(0)
For more than one concurrent timer, reach for signal.setitimer(which, seconds, interval=0.0). There are three flavors: ITIMER_REAL (wall clock), ITIMER_VIRTUAL (CPU time in the process), and ITIMER_PROF (CPU time plus system time on behalf of the process). setitimer accepts floats; alarm does not. Bad arguments raise signal.ItimerError, a subclass of OSError.
A real-world use: bound a network read so a hung server cannot stall your CLI forever. Wrap the read in a try/finally, install the SIGALRM handler, set a generous alarm() budget, and let the TimeoutError propagate up to whatever context manager owns the connection.
How do I wait cooperatively for a signal?
signal.pause() sleeps until a signal is received, the handler runs, and pause() returns. It is the right primitive if you genuinely have nothing better to do while waiting.
If a worker thread needs to block until a specific signal arrives, use signal.sigwaitinfo(sigset) in that thread, never in the main thread. The docs warn that mixing signal.signal() handlers with signal.sigwait() is undefined behavior, because both paths claim ownership of the signal:
import signal, threading
def worker():
siginfo = signal.sigwaitinfo({signal.SIGUSR1, signal.SIGUSR2})
print("got", siginfo.si_signo)
threading.Thread(target=worker, daemon=True).start()
signal.pthread_sigmask(how, mask) is the way to block signals across a critical section. Pass SIG_BLOCK to add masks, SIG_UNBLOCK to remove them, and SIG_SETMASK to install a fresh set. SIGKILL and SIGSTOP cannot be blocked, even here. Use this when you need a piece of code to run atomically relative to a signal, then return to normal handling afterward.
How do I send a signal to another process?
os.kill(pid, sig) is the cross-platform way to send a signal to a process. The name is misleading: it sends whatever signal you pass, and os.kill(pid, 0) checks whether the process is alive without sending anything. To send a polite shutdown to a child you started yourself, the subprocess guide and os.kill go together naturally:
import os, signal, subprocess, time
p = subprocess.Popen(["python", "long_script.py"])
time.sleep(2)
os.kill(p.pid, signal.SIGTERM)
p.wait(timeout=10)
To send a signal to your own process, use signal.raise_signal(signum) (added in 3.8). It is a tidier replacement for os.kill(os.getpid(), sig), with no platform branching.
Cross-platform notes
On Windows there is no real SIGTERM; process managers fake it. Windows does have signal.SIGBREAK (Ctrl-Break) and the special signal.CTRL_C_EVENT and signal.CTRL_BREAK_EVENT constants, but those last two are only accepted by os.kill(), not by signal.signal(). To send Ctrl-C to another Windows process:
import os, signal
os.kill(child_pid, signal.CTRL_C_EVENT)
On wasm32-emscripten and other WebAssembly targets there are no real POSIX signals. The signal module is partially emulated, and many of the constants and functions in this guide are missing. Do not rely on signal in browser-based Python.
Common gotchas
A handful of non-obvious things will trip you up the first time you write a signal handler. Most of them come from the C-level signal machinery underneath CPython, which behaves differently from regular Python control flow — handlers run on a different stack, exceptions surface in the wrong place, and locks held by the same thread will deadlock. The list below distills the “General rules” section of the official signal documentation into the items that have bitten real production code, with a short note on what to do instead.
The synchronous-fault trap is the one most people fall into first. This is the wrong way to handle a crash:
# Do not do this — it will hang the process
import signal
def bad(signum, frame):
print("caught fault")
for sig in (signal.SIGSEGV, signal.SIGFPE, signal.SIGBUS, signal.SIGILL):
signal.signal(sig, bad)
Python returns to the C code that raised the fault, the C code re-faults, and the process spins. Use the faulthandler module instead — it dumps a Python traceback and lets the process die.
- Do not catch synchronous faults.
SIGFPE,SIGSEGV,SIGBUS, andSIGILLare generated by invalid C-level operations. Python returns to the C code, which re-faults and the process hangs. Use thefaulthandlermodule instead. - Handler exceptions land on the main thread “out of thin air”. They are not propagated back to wherever the C handler ran, so they can corrupt program state in confusing ways. Keep handlers small and free of side effects beyond setting a flag.
- No locks inside handlers. Acquiring a
threading.Lockfrom a handler is a classic deadlock if the same thread already holds it. Set anEventor write to a pipe instead. signal.signal()makes the signal interruptible. Installing a handler implicitly callssiginterrupt(signalnum, True), so a blocked syscall will raiseInterruptedErrorinstead of restarting. That is the source of many low-levelEINTR-style bugs.- Signal numbers are platform-dependent. Always go by name (
signal.SIGUSR1), never by literal number.signal.valid_signals()(added in 3.8) returns the set of signals the running platform supports, which is handy when you want to know what you can rely on. - Child processes inherit signal disposition. When you start a child with
subprocess.Popen, the child starts with the same handlers unless you set up your own. ASIGCHLDhandler in the parent is the standard way to reap zombies.
Putting it all together: a Python signal handling example
Here is a complete, runnable sketch of a long-running service that shuts down cleanly on either Ctrl-C or a process-manager SIGTERM:
import logging
import signal
import threading
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger(__name__)
stop = threading.Event()
def request_stop(signum, frame):
log.info("received signal %d, shutting down", signum)
stop.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
state_path = Path("service-state.json")
try:
while not stop.is_set():
# main work goes here
stop.wait(0.5)
finally:
state_path.write_text('{"status": "stopped"}')
log.info("state flushed, exiting")
The sketch uses logging rather than print so the same code runs cleanly under a real service manager. The stop.wait(0.5) inside the main loop bounds how long the program takes to notice a signal, and the try/finally guarantees the state file is written even if the handler raises before the loop exits. Drop your real workload into the loop body where the comment sits, and the shutdown path stays intact.
# output (after receiving SIGTERM from `kill <pid>`)
2026-06-14 06:07:00,000 received signal 15, shutting down
2026-06-14 06:07:00,001 state flushed, exiting
The handler is one line. The main loop checks the flag, the finally block runs even on Ctrl-C, and the whole thing exits within half a second of receiving a signal. Add your own work where the comment says so.
Frequently asked questions
How do I catch Ctrl-C in Python?
The default SIGINT handler raises KeyboardInterrupt in the main thread, so the simplest option is to wrap your top-level work in try: ... except KeyboardInterrupt: and clean up there. If you need a custom shutdown path — say, flushing a database connection or finishing a batch job — install your own handler with signal.signal(signal.SIGINT, handler) and have it set a flag that the main loop watches. The full pattern is in the “How do I shut down gracefully” section above.
What is the difference between SIGINT and SIGTERM?
SIGINT (signal 2) is what terminals send when you press Ctrl-C. SIGTERM (signal 15) is what systemctl stop, Docker, Kubernetes, and most process managers send to ask a program to exit. Both are catchable, and both default to terminating the process if you do not install a handler. Production code should handle both, because you cannot control which one a container runtime will use, and the operator who sends SIGTERM expects your program to do useful cleanup before exiting.
Can I send a signal from inside Python?
Yes. Use os.kill(pid, sig) to send a signal to another process, or signal.raise_signal(sig) (added in 3.8) to send a signal to your own process. The name os.kill is misleading — it sends whatever signal number you pass, and os.kill(pid, 0) only checks liveness without actually delivering anything. For cross-platform Ctrl-C delivery on Windows, pass signal.CTRL_C_EVENT to os.kill() rather than signal.signal().
Why does my handler not run during a long-running C call?
Signal handlers in CPython fire between bytecode instructions, not inside C extension calls. If your main thread is blocked in a tight NumPy reduction, a large re match, or a socket.recv() waiting for data, the handler will not run until control returns to the Python interpreter. This is a CPython implementation detail, not a Python language rule, and it is the reason people often think “my handler is broken” when in fact it has not had a chance to run yet.
Should I use signal.alarm or signal.setitimer for timeouts?
Reach for signal.alarm(seconds) when you only need one timer with an integer-second budget. It is the simplest API and it works everywhere. If you need float seconds or multiple concurrent timers, use signal.setitimer(which, seconds, interval=0.0) with one of the ITIMER_REAL, ITIMER_VIRTUAL, or ITIMER_PROF flavors. Both raise signal.ItimerError (a subclass of OSError) on bad input, and both are POSIX-only — there is no equivalent on Windows.
Conclusion
Python signal handling is small but quirky. The rules to remember: handlers only run on the main thread, keep them tiny and async-signal-safe, and prefer setting a flag over doing real work inside the handler. For cooperative shutdown, the threading.Event pattern is the workhorse. For timeouts, signal.alarm covers the simple case and signal.setitimer covers everything else. The full surface is documented at the signal module reference; the error handling guide covers the try/except KeyboardInterrupt side of the same problem.
See also
signalmodule reference: the full stdlib surface, including theSignals,Handlers, andSigmasksenums added in 3.5.osmodule reference:os.kill,os.killpg, and the process-management primitives that send signals.subprocessmodule reference: starting child processes that share, or override, your signal disposition.atexitmodule reference: a complementary “process is exiting” hook for final cleanup that does not depend on a specific signal.