Signals
The signal module lets Python code register handlers for operating-system signals like SIGINT and SIGTERM, the mechanism behind Ctrl+C and graceful process shutdown.
A signal is an operating-system-level notification sent to a process — asking it to terminate, telling it a child process exited, or reporting a fatal error. Python's signal module lets you register Python functions as handlers for the signals your process can catch, which is how programs implement graceful shutdown on Ctrl+C or in response to a supervisor sending SIGTERM.
What Is a Signal
Signals are a Unix-originated mechanism (with a smaller, partial equivalent on Windows) for the operating system or another process to asynchronously notify a running process of an event. Common examples: SIGINT (interrupt, sent by Ctrl+C in a terminal), SIGTERM (a polite request to terminate, the default sent by kill and by most process supervisors), and SIGKILL (an unconditional, uncatchable kill).
Signal handling is fundamentally an operating-system-level concept exposed to Python by the signal module; its exact set of available signals and behavior differs between Unix-like systems and Windows.
The signal Module Basics
signal.signal(signalnum, handler) registers handler to run when signalnum is received; handler is called with two arguments (the signal number and the current stack frame) when the signal arrives. Passing signal.SIG_DFL restores the operating system's default action; passing signal.SIG_IGN causes the signal to be ignored entirely.
Instead of the default behavior (raising KeyboardInterrupt and terminating), Ctrl+C now runs handle_sigint and the program continues.
import signal
import time
def handle_sigint(signum, frame):
print(f"received signal {signum}, ignoring for now")
signal.signal(signal.SIGINT, handle_sigint)
print("running - press Ctrl+C to test the handler")
time.sleep(10)
print("finished sleeping")Default Handlers: SIG_DFL and SIG_IGN
Every signal has an OS-defined default action if no handler is registered — for most signals this is process termination, sometimes with a core dump. SIG_DFL explicitly restores that default (useful if you'd previously changed it); SIG_IGN tells the OS to discard the signal, which is different from doing nothing in a Python handler (it happens at the OS level with essentially no overhead, and cannot be interrupted mid-handling).
SIGINT and KeyboardInterrupt
By default, CPython installs its own handler for SIGINT that raises KeyboardInterrupt in the main thread at the next opportunity — this is why ordinary Python programs stop with a traceback on Ctrl+C without you writing any signal code. Registering your own SIGINT handler with signal.signal() replaces this default behavior.
This relies on CPython's built-in SIGINT-to-KeyboardInterrupt translation rather than the signal module directly — often simpler than a custom handler for straightforward Ctrl+C handling.
import time
try:
print("running - Ctrl+C raises KeyboardInterrupt by default")
time.sleep(10)
except KeyboardInterrupt:
print("caught KeyboardInterrupt, shutting down cleanly")SIGTERM and Common Signals
SIGTERM is the standard 'please terminate' signal sent by process managers, container orchestrators, and the plain kill command by default; unlike SIGKILL, a process can catch it and perform cleanup before exiting. Catching SIGTERM (and often SIGINT) with the same graceful-shutdown handler is the standard way to make a long-running service exit cleanly rather than being abruptly killed.
SIGINT interrupt from keyboard (Ctrl+C) catchable
SIGTERM polite termination request catchable
SIGKILL unconditional termination NOT catchable, NOT ignorable
SIGHUP terminal hangup / reload request catchable (Unix only)Main-Thread Restriction
CPython implementation detail: signal.signal() can only be called from the main thread of the main interpreter, and registered handlers only ever actually run in the main thread, regardless of which thread was executing when the signal arrived at the OS level. Calling signal.signal() from a non-main thread raises ValueError. This follows from how CPython's signal handling integrates with its bytecode evaluation loop.
import signal
import threading
def try_register():
try:
signal.signal(signal.SIGTERM, lambda s, f: None)
except ValueError as exc:
print(f"failed as expected: {exc}")
t = threading.Thread(target=try_register)
t.start()
t.join()
# failed as expected: signal only works in main thread of the main interpreterWriting Handlers Safely
A signal handler can interrupt normal execution at almost any point, including in the middle of otherwise-atomic-looking operations, so handlers should do as little as possible — typically just setting a flag that the main program checks periodically, rather than performing complex logic, I/O, or acquiring locks directly inside the handler. This avoids reentrancy hazards and reduces the risk of leaving shared state inconsistent.
The main loop checks the flag between iterations of real work rather than doing cleanup inside the handler itself, keeping the handler trivially simple and safe.
import signal
import time
shutdown_requested = False
def request_shutdown(signum, frame):
global shutdown_requested
shutdown_requested = True # minimal work: just set a flag
signal.signal(signal.SIGTERM, request_shutdown)
signal.signal(signal.SIGINT, request_shutdown)
while not shutdown_requested:
print("working...")
time.sleep(1)
print("shutdown flag seen, cleaning up and exiting")Graceful Shutdown Pattern
A common production pattern: register handlers for SIGTERM (and often SIGINT) that set a shutdown flag or event; the main loop (or worker threads/processes) periodically checks that flag, finishes or aborts in-flight work cleanly, releases resources (closes files, connections, flushes buffers), and exits — rather than being killed mid-operation with no chance to clean up.
threading.Event is a natural fit here: the handler just calls .set(), and any thread (main loop, workers) can efficiently wait on or poll .is_set() without extra locking.
import signal
import threading
import time
shutdown_event = threading.Event()
def handle_shutdown(signum, frame):
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
def main_loop() -> None:
while not shutdown_event.is_set():
print("processing a unit of work")
time.sleep(1)
print("shutdown signal received, cleaning up resources")
main_loop()signal.alarm and Timers
signal.alarm(seconds) (Unix only) schedules SIGALRM to be delivered after the given number of seconds, useful for enforcing a timeout on a blocking operation by installing a SIGALRM handler that raises an exception. This is Unix-specific; portable timeout code more commonly uses threading.Timer or a library-level timeout parameter instead.
Unix vs Windows Differences
Windows supports only a small subset of signals through the signal module — SIGINT, SIGTERM (delivered in a limited, non-POSIX way), SIGBREAK (Windows-specific), and SIGABRT are about the extent of it; there is no SIGHUP, no SIGALRM, and no general POSIX-style signal set. Cross-platform code that needs graceful shutdown typically restricts itself to SIGINT/SIGTERM and tests actual behavior on every target platform rather than assuming Unix semantics apply everywhere.
Interaction With subprocess and os
os.kill(pid, sig) sends a signal to another process (or the current one) by PID; the subprocess module's Popen.terminate() and Popen.send_signal() build on this for managing child processes started through subprocess. A parent process gracefully shutting down its own children typically sends SIGTERM first (giving them a chance to clean up) and only escalates to SIGKILL if they don't exit within a timeout.
Best Practices
Keep signal handlers minimal — set a flag or Event, do real work in the main loop. Register handlers only from the main thread, and do it early in program startup. Handle both SIGINT and SIGTERM for graceful shutdown in long-running services. Never assume a signal you registered a handler for is guaranteed to run before the process exits (SIGKILL cannot be caught at all, and some environments kill processes forcefully after a grace period).
Complete Example
import signal
import time
class GracefulWorker:
def __init__(self) -> None:
self._shutdown = False
signal.signal(signal.SIGINT, self._on_signal)
signal.signal(signal.SIGTERM, self._on_signal)
def _on_signal(self, signum, frame) -> None:
self._shutdown = True
def run(self) -> None:
print("worker started")
while not self._shutdown:
print("doing work")
time.sleep(1)
print("shutdown requested, releasing resources")
# ... close files, flush buffers, disconnect, etc.
print("worker exited cleanly")
if __name__ == "__main__":
GracefulWorker().run()Mental Model
Think of signals as the operating system tapping your process on the shoulder from outside its normal control flow. A registered handler is a note taped to the process saying 'when tapped, do this first' — and the sanest note to leave is a short one ('set a flag'), because you never know exactly what the process was doing the instant the tap arrived.
Quick Reference
signal.signal(sig, handler) register a handler (main thread only)
signal.SIG_DFL / SIG_IGN restore default / ignore a signal
signal.SIGINT / SIGTERM Ctrl+C / polite terminate request
signal.SIGKILL uncatchable, unconditional kill (Unix)
signal.alarm(seconds) schedule SIGALRM (Unix only)
os.kill(pid, sig) send a signal to a process by PIDKey Takeaways
Signals are OS-level asynchronous notifications; the signal module lets Python register handlers for the ones a process can catch.
CPython restricts registering and running signal handlers to the main thread of the main interpreter.
Handlers should do minimal work — typically setting a flag or Event — because a signal can arrive at essentially any point in program execution.
SIGTERM and SIGINT are the standard signals for graceful shutdown; SIGKILL cannot be caught or ignored, so it never allows cleanup.
Windows supports only a limited signal set compared to Unix — cross-platform graceful-shutdown code should be tested on every target platform.
def handler(signum, frame): cleanup_database() # complex work, I/O, locking — inside the handler itself write_log_file() close_all_connections() signal.signal(signal.SIGTERM, handler) shutdown_requested = False def handler(signum, frame): global shutdown_requested shutdown_requested = True # minimal: just flip a flag signal.signal(signal.SIGTERM, handler) # main loop checks the flag and does the real cleanup there while not shutdown_requested: do_work() cleanup_database() write_log_file() close_all_connections()
A handler can interrupt code at almost any point, including mid-operation on shared state; doing complex work directly inside it risks reentrancy bugs and inconsistent state. Setting a flag and doing real cleanup in the main loop is far safer.
CPython only allows signal.signal() to be called from the main thread of the main interpreter; calling it from any other thread raises ValueError.
What does signal.signal(signal.SIGTERM, handler) do?
It registers handler as the function to be called when the process receives SIGTERM, replacing the operating system's default action (normally termination) for that signal.
Why can't SIGKILL be handled with a custom signal handler?
SIGKILL is designed by the operating system to be unconditional and uncatchable/unignorable specifically so there is always a way to force-terminate a process regardless of what it's doing, including a misbehaving or unresponsive handler for other signals.
Why does CPython restrict signal.signal() and handler execution to the main thread?
This is a CPython implementation detail tied to how signal delivery integrates with the interpreter's bytecode execution loop; registering from or running handlers in other threads is not supported, and attempting to register from a non-main thread raises ValueError.
Why is it good practice for a signal handler to just set a flag rather than perform complex cleanup directly?
A signal can interrupt the program at almost any point, including in the middle of operations on shared state; doing complex work inside the handler risks reentrancy issues and leaving data structures inconsistent. Setting a flag and doing the real work in the main loop, at a known-safe point, avoids that.
What is the difference between SIGINT and SIGTERM in typical usage?
SIGINT is sent by an interactive interrupt like Ctrl+C in a terminal; SIGTERM is the standard signal sent by process managers, orchestrators, and the kill command to politely ask a process to terminate. Both are catchable, unlike SIGKILL.
Does the signal module behave identically on Windows and Unix-like systems?
No — Windows supports only a small subset of signals (roughly SIGINT, a limited SIGTERM, SIGBREAK, SIGABRT) with no general POSIX-style signal set, no SIGHUP, and no SIGALRM, so cross-platform signal-handling code must be written and tested with that limited common subset in mind.
Write a program that registers a handler for SIGINT and SIGTERM that sets a threading.Event, and a main loop that runs until the event is set, then prints a cleanup message.
import signal import threading import time shutdown_event = threading.Event() def handle_signal(signum, frame): shutdown_event.set() signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) while not shutdown_event.is_set(): print("working...") time.sleep(1) print("shutdown signal received, cleaning up")
Both SIGINT and SIGTERM are routed to the same minimal handler, which only sets the Event; the main loop polls is_set() (implicitly, via the while condition) between units of work and performs the actual cleanup once it exits the loop, keeping the handler itself trivially simple and safe.
What happens when this program runs, and why?
The thread's call to signal.signal() raises ValueError because CPython only allows registering (or running) signal handlers from the main thread of the main interpreter; since the exception isn't caught, it propagates and crashes the thread (printed as an unhandled exception in the thread, while 'done' still prints afterward since join() itself doesn't re-raise the thread's exception).
Explain why relying on a registered SIGTERM handler to guarantee cleanup code always runs before a process exits is not safe in general.
A process can still be terminated without running any handler — via SIGKILL (uncatchable), a hard crash, a host-level force-kill after a grace period expires, or the OS/environment terminating it abruptly (e.g. power loss) — so a SIGTERM handler improves the odds of clean shutdown but is not an absolute guarantee.
import signal
import threading
def register():
signal.signal(signal.SIGINT, lambda s, f: None)
t = threading.Thread(target=register)
t.start()
t.join()
print("done")