Skip to content
shellhacks.dev
Python

How to Handle Signals and Cleanup in Python Scripts

Handle SIGINT and SIGTERM in Python scripts: stop a loop cleanly, run cleanup with try and finally, use atexit, and know which signals cannot be caught.

6 min read
Summarize in AI
Illustration for "How to Handle Signals and Cleanup in Python Scripts": a shell script in an editor, a signal bolt, a regular expression and its match.

A script that dies halfway through a job leaves a lock file, a temporary directory or a half-written output behind. systemd, Docker and Kubernetes all stop processes with SIGTERM, and plain Python does not do anything special with it. This post shows the small pattern that turns a termination signal into an orderly exit. It is the Python version of the shell approach in trap and cleanup. Examples run on Python 3.13 on Linux.

A loop that stops cleanly

worker.pyPython
import signal
import sys
import time

stop = False

def request_stop(signum, frame):
    global stop
    stop = True

signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)

try:
    while not stop:
        print("working")
        time.sleep(1)
finally:
    print("cleaning up")
sys.exit(0)
Shell
python3 worker.py &
kill -TERM $!
Text
working
cleaning up

The handler only sets a flag, so the loop finishes its current step and leaves in an orderly way. That is safer than doing cleanup inside the handler, which can interrupt the main code at any point, including in the middle of a write that you are about to clean up. The time.sleep(1) returns early when the signal arrives, so the flag is noticed within the second.

Convert a signal into an exception

Python
def to_exit(signum, frame):
    raise SystemExit(128 + signum)

signal.signal(signal.SIGTERM, to_exit)

try:
    run_job()
finally:
    remove_lock_file()

When the work cannot check a flag, such as a long call into a library, raise SystemExit from the handler. The finally block then runs, and the exit status is 128 + 15 = 143, the shell convention for death by SIGTERM. Supervisors that look at the code can tell a requested stop from a failure.

atexit for plain cleanup

Python
import atexit, shutil, tempfile

workdir = tempfile.mkdtemp()
atexit.register(shutil.rmtree, workdir, ignore_errors=True)

atexit callbacks run when the interpreter exits normally, including after sys.exit() and an unhandled exception. They do not run when the process is killed by a signal that has no handler, which is why the handler above matters. For a temp directory, tempfile.TemporaryDirectory() as a context manager is clearer than atexit.

Threads, asyncio and child processes

Signal handlers execute only in the main thread. For worker threads, share a threading.Event and have each loop check event.is_set(). In asyncio, use loop.add_signal_handler(signal.SIGTERM, callback), which runs the callback in the event loop. If the script starts subprocesses, forward the signal or they keep running: start them with start_new_session=True and send os.killpg(proc.pid, signal.SIGTERM). See subprocess timeouts for the full pattern.

Where this does not apply

SIGKILL, which the kernel sends for an out-of-memory kill and which kill -9 sends, cannot be caught, so a script must be written so that dying at any line is survivable: write to a temporary name and rename, as in safe temp files. Windows supports only a few signals, mainly SIGINT and SIGBREAK. For services under systemd, set TimeoutStopSec to give the handler time, and see signals and PID 1 in containers, where a Python process running as PID 1 gets no default signal behavior.

When Python runs your handler

A Python signal handler does not run at the instant the signal arrives. The C-level handler only sets a flag, and the interpreter runs your Python function in the main thread between bytecode instructions. Three consequences follow. A handler runs only in the main thread, and signal.signal() raises ValueError if you call it from another thread. A long call in C code, such as a large regular expression match or a numeric routine, delays the handler until it returns. And blocking system calls such as time.sleep(), socket reads and subprocess.wait() are interrupted so the handler can run, then resumed automatically on current Python versions, so a loop waiting on a read keeps waiting after the handler returns unless the handler raises or sets a flag you check.

That last point is why the usual pattern is a flag. The handler sets a variable or an threading.Event, and the main loop checks it and exits at a safe point. Do as little as possible in the handler itself. Calling logging, taking locks or doing file work inside it can deadlock, since the handler may have interrupted code that already holds the same lock, and logging holds an internal lock while it writes.

Which signals can be caught

You can catch SIGTERM, SIGINT and SIGHUP, and no handler runs for SIGKILL or SIGSTOP. This sets the design limit for cleanup: a process that is killed with kill -9, or by the kernel's out-of-memory killer, gets no chance to tidy up. Anything that must be consistent after such a death has to be built to survive it, for example by writing files to a temporary name and renaming them when complete, using a lock file that a later run can detect as stale, or keeping state in a database with transactions. Cleanup code handles the polite exits, and the design handles the rest.

By default SIGINT raises KeyboardInterrupt, so a try and finally block already cleans up on Control-C. SIGTERM has no such default in Python. The process terminates immediately, without running finally blocks or atexit functions, which is a common surprise for services stopped by systemd or Docker. Installing a handler that turns SIGTERM into a normal exit, with sys.exit(0) or a stop flag, is what makes shutdown clean.

Containers, supervisors and the grace period

Supervisors send SIGTERM first and SIGKILL after a timeout, 10 seconds by default in Docker and 90 seconds in systemd. Your cleanup must finish inside that window, so keep it short: stop accepting work, finish or abandon the current item, flush, close and exit. In a container the Python process is often PID 1, which receives no default signal actions from the kernel, so a process that does not install its own handler ignores SIGTERM altogether and gets killed after the timeout. Install handlers explicitly, or run an init such as tini that forwards signals and reaps children, as the Docker flag --init does.

Test the shutdown path as you would any other. Start the program, send SIGTERM with kill, and check the exit status and that temporary files are gone. A shutdown routine that has never run is likely to be broken.

Signals in threads, asyncio and child processes

In a program with threads, the main thread receives the signal handler call, and a worker thread that is blocked will not wake on its own. The handler should set an event that the workers check in their loops, and the main thread should then join them with a timeout. Daemon threads are killed abruptly at interpreter exit, with no chance to clean up, so use ordinary threads for work that must finish. With asyncio, use loop.add_signal_handler on Unix, which runs a normal callback inside the loop and is safe to use for cancelling tasks, and asyncio.run already turns Control-C into a cancellation of the main task on Python 3.11 and later. Child processes receive Control-C from the terminal along with you, since they share the process group, but not a SIGTERM you send to the parent, so forward it or terminate the children in your cleanup.

Use atexit only for plain cleanup such as removing a temporary directory. It runs at normal exit and after sys.exit, and not after a signal that kills the process or os._exit.

See the signal module documentation and atexit.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.