The signal module lets Python programs react to asynchronous events delivered to a process, including Ctrl+C, operating-system termination requests, interval timers, and child-process notifications. Command-line tools, servers, and workers use signals to release resources and stop without corrupting data.
Python has important execution rules. A Python handler runs later in the main thread of the main interpreter rather than inside the low-level native signal handler. Only that main thread may install a new handler. These rules shape every safe multithreaded shutdown design.
Common signals
SIGINT normally comes from Ctrl+C and raises KeyboardInterrupt by default. SIGTERM is the standard graceful termination request sent by systemd, Docker, Kubernetes, and the Unix kill command. On Unix, SIGHUP may indicate a closed controlling terminal or an application-defined reload request.
SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. Cleanup code cannot run after SIGKILL.
Install a minimal handler
import signal
stop_requested = False
def request_stop(signum, frame):
global stop_requested
stop_requested = True
signal.signal(signal.SIGINT, request_stop)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, request_stop)
A handler receives the signal number and the current frame. For graceful shutdown, keep it minimal: set a flag, write to a prepared non-blocking descriptor, or notify a safe coordination mechanism.
Do not perform heavy cleanup in a handler
Avoid writing large files, closing hundreds of connections, acquiring locks, or calling slow services directly from the handler. The official documentation warns that synchronization primitives such as threading.Lock can cause unexpected deadlocks in signal handlers.
Let the handler notify normal application code. That code can perform cleanup with controlled exception handling and ordering.
A cooperative service loop
import signal
import time
stopping = False
def request_stop(signum, frame):
global stopping
stopping = True
signal.signal(signal.SIGINT, request_stop)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, request_stop)
while not stopping:
process_next_task()
time.sleep(0.2)
close_resources()
The loop checks the flag between work units. Long jobs should provide cancellation points. A long-running C function may delay Python handler execution until it returns control to the interpreter.
Signals and threads
Even when a signal is delivered to another operating-system thread, the Python handler executes in the main thread. Signals are not an inter-thread messaging API. Use threading.Event, queues, or other synchronization primitives for that purpose.
import signal
import threading
shutdown_event = threading.Event()
def handler(signum, frame):
shutdown_event.set()
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, handler)
Only install the handler from the main thread. Calling signal.signal() in a worker raises ValueError.
Avoid using KeyboardInterrupt as the shutdown architecture
A signal handler that raises an exception can make that exception appear after almost any bytecode instruction in the main thread. Complex applications may be interrupted while state is partially updated or after a lock is acquired but before cleanup is registered.
An explicit SIGINT handler that sets a shutdown flag makes the transition predictable: finish or cancel current work, reject new work, then close components in order.
Server shutdown sequencing
A server can stop accepting new connections, allow in-flight requests to finish within a deadline, cancel remaining tasks, close pools, flush logs, and exit.
The guide to Python socketserver explains that shutdown() must be called from another thread while serve_forever() is running. The signal handler should notify that thread rather than blocking itself.
SIGTERM in containers
Container orchestrators usually send SIGTERM, wait for a grace period, and then send SIGKILL. A Python service should handle SIGTERM, stop new input, and complete cleanup before the deadline.
Use the exec form of the container command so Python becomes the process receiving the signal. An intermediate shell may fail to forward signals correctly.
SIGPIPE and BrokenPipeError
Python ignores SIGPIPE by default so writes to closed pipes and sockets become BrokenPipeError. Do not restore the default SIGPIPE disposition merely to hide exceptions, because an interrupted network connection could terminate the whole process unexpectedly.
Python errno explains EPIPE and related system failures.
Timeouts with alarm()
On Unix, signal.alarm() schedules SIGALRM after an integer number of seconds. Only one alarm exists per process, so a later call replaces the earlier one.
import signal
class OperationTimeout(TimeoutError):
pass
def timeout_handler(signum, frame):
raise OperationTimeout("Operation exceeded the deadline")
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(5)
try:
blocking_operation()
finally:
signal.alarm(0)
This is Unix-only, main-thread-only, and introduces an asynchronous exception. Prefer the native timeout option of a socket, HTTP client, database driver, or subprocess API whenever available.
Higher-resolution interval timers
setitimer() supports fractional seconds and recurring intervals. ITIMER_REAL delivers SIGALRM, ITIMER_VIRTUAL measures process CPU time, and ITIMER_PROF combines process and kernel time.
Signal timers are process-global and may conflict with libraries. Document ownership and restore previous values when possible.
Discover available signals
import signal
for number in sorted(signal.valid_signals(), key=int):
try:
name = signal.Signals(number).name
description = signal.strsignal(number)
print(number, name, description)
except ValueError:
pass
The available set varies by platform. Use hasattr() and valid_signals() instead of assuming every Unix signal exists on Windows or WebAssembly.
Restore previous handlers
signal.signal() returns the previous handler. A library that temporarily changes a disposition should restore it.
previous = signal.signal(signal.SIGINT, handler)
try:
run_operation()
finally:
signal.signal(signal.SIGINT, previous)
Reusable libraries should not permanently overwrite policy owned by the main application.
Wake event loops with set_wakeup_fd()
A loop blocked in select, poll, or another multiplexer may need a file descriptor to become readable when a signal arrives. set_wakeup_fd() writes the signal number as one byte to a non-blocking descriptor for signals that have registered handlers.
import os
import signal
read_fd, write_fd = os.pipe()
os.set_blocking(read_fd, False)
os.set_blocking(write_fd, False)
signal.set_wakeup_fd(write_fd)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, lambda signum, frame: None)
The event loop must drain the descriptor. Buffers are finite, so choose warn_on_full_buffer according to whether individual signal bytes matter. The next guide on select applies this pattern.
Blocking and synchronously waiting on Unix
pthread_sigmask() changes a thread’s signal mask. sigwait(), sigwaitinfo(), and sigtimedwait() let a dedicated Unix thread synchronously accept signals from a blocked set.
This can simplify complex services: block selected signals before creating workers, then dedicate one thread to waiting and translating them into application events. Test the exact platform and imported libraries.
Do not recover from SIGSEGV in Python
Synchronous faults such as SIGSEGV, SIGBUS, or SIGFPE caused by native code cannot be repaired by a normal Python signal handler. Returning from the handler often resumes the same invalid instruction.
Use faulthandler for diagnostics. The guide to Python ctypes recommends isolating unstable native libraries in subprocesses.
Subprocesses and process groups
Decide whether termination should target only a direct child or an entire process group. terminate() and kill() have platform-specific semantics. A service that launches process trees should avoid orphaned grandchildren.
On Linux, pidfds can reduce risks related to PID reuse. Never signal a reused PID without verifying process identity.
Idempotent handling
Handlers can run more than once. The first signal may begin graceful shutdown, while later signals can shorten the deadline or be ignored.
stopping = False
def handler(signum, frame):
global stopping
if stopping:
return
stopping = True
Cleanup steps should also tolerate already-closed components and repeated cancellation requests.
Logging and handlers
Complex logging may acquire internal locks. Prefer recording the shutdown reason in the normal loop after it wakes. Keep handler-side diagnostics minimal and platform-tested.
Convert signals into ordinary events
A robust architecture turns the asynchronous signal into an ordinary state transition. The handler writes to a wakeup descriptor or sets a flag, the selector returns, and normal code executes a documented shutdown state machine.
For interrupted system calls, see Python errno. For native crashes and callbacks, revisit Python ctypes.
Recommended tests
Test SIGINT and SIGTERM during idle waits, active processing, output writes, and shutdown; repeated signals; a long C function; registration from a non-main thread; missing Unix signals on Windows; a full wakeup buffer; alarm cancellation; subprocess groups; and container grace periods.
Run signal tests inside child processes so they do not interrupt the test runner. Assert exit status, cleanup markers, and shutdown duration.
Common mistakes
Common failures include heavy cleanup in the handler, acquiring locks, trying to recover from SIGSEGV, relying only on KeyboardInterrupt, forgetting SIGTERM in containers, assuming Unix signals exist everywhere, failing to restore handlers, and leaving an event loop blocked without a wakeup descriptor.
Conclusion
signal turns operating-system events into cooperative shutdown, timers, and controlled notifications. Keep handlers minimal and move real work to the normal application flow.
Respect the main-thread rule, support SIGTERM and SIGINT, make cleanup idempotent, and test behavior in subprocesses. Consult the official signal documentation and the Linux signal(7) manual.







