Your CLI handles Ctrl+C beautifully — temp files removed, lock released, a one-line summary printed. Then someone runs it in a container, and docker stop leaves a stale lock file and half-written output behind. Or a CI job hits its timeout, and the "cleanup" step finds your process's debris. The reason is that supervisors do not press Ctrl+C: they send SIGTERM, and Python's default response to SIGTERM is to terminate at once, without raising an exception, without running finally blocks or context-manager exits, and without flushing buffered output. This guide shows how to give SIGTERM the same graceful treatment as SIGINT, how to fit shutdown inside a supervisor's grace period, what changes when your CLI is PID 1 in a container, and how to test it. It belongs to the long-running and watch-mode topic.
Prerequisites
- Python 3.10+ on Linux or macOS (Windows notes at the end).
- A CLI whose cleanup already works for Ctrl+C, as described in handling KeyboardInterrupt cleanly.
- Something that will stop it with
SIGTERM: systemd, Docker, Kubernetes,timeout(1), a CI runner or a process manager.
What happens on docker stop
Every supervisor follows the same protocol: send SIGTERM, wait a grace period, then send SIGKILL, which cannot be caught.
The grace periods differ: docker stop waits 10 seconds, Kubernetes 30 (terminationGracePeriodSeconds), systemd 90 (TimeoutStopSec), and timeout(1) sends only SIGTERM unless you add --kill-after. Your shutdown has to fit comfortably inside the shortest one your users will hit. Anything still running when SIGKILL arrives — including finally blocks — simply stops.
The recipe: two patterns
Which pattern to use depends on the shape of the command.
One-shot commands — a build, an export, a migration — are written as ordinary sequential code with try/finally and with blocks for cleanup. For them, the simplest correct approach is to make SIGTERM raise an exception, exactly as SIGINT raises KeyboardInterrupt, so the existing cleanup runs:
# src/mytool/signals.py
from __future__ import annotations
import signal
import sys
from collections.abc import Iterator
from contextlib import contextmanager
class Terminated(BaseException):
"""Raised in the main thread when SIGTERM arrives."""
def __init__(self, signum: int) -> None:
super().__init__(signum)
self.signum = signum
self.exit_code = 128 + signum
def _raise(signum: int, frame: object) -> None:
# Restore the default first: a second SIGTERM during slow cleanup kills us outright.
signal.signal(signum, signal.SIG_DFL)
raise Terminated(signum)
@contextmanager
def terminate_as_exception() -> Iterator[None]:
previous = signal.signal(signal.SIGTERM, _raise)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, _raise) # terminal closed
try:
yield
finally:
signal.signal(signal.SIGTERM, previous)
Terminated derives from BaseException, like KeyboardInterrupt, so ordinary except Exception: blocks in your code and in libraries do not swallow it. Wire it in at the top of the command layer, where KeyboardInterrupt is already handled:
# src/mytool/cli.py
import shutil
import tempfile
import time
from pathlib import Path
import typer
from mytool.signals import Terminated, terminate_as_exception
app = typer.Typer()
@app.callback()
def main() -> None:
"""Export tools."""
@app.command()
def export(out: Path, items: int = 50) -> None:
"""Export ITEMS records to OUT, cleaning up if stopped."""
with terminate_as_exception():
work = Path(tempfile.mkdtemp(prefix="mytool-export-"))
try:
for i in range(items):
(work / f"{i:04}.json").write_text("{}")
time.sleep(0.05) # stand-in for real work
shutil.copytree(work, out, dirs_exist_ok=True)
typer.echo(f"exported {items} records to {out}", err=True)
except KeyboardInterrupt:
typer.echo("interrupted; nothing written", err=True)
raise typer.Exit(130)
except Terminated as exc:
typer.echo(f"stopped by signal {exc.signum}; nothing written", err=True)
raise typer.Exit(exc.exit_code)
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
app()
Loop-shaped commands — watchers, workers, pollers — are better served by a stop event than an exception: the handler sets a flag, and the loop checks it between units of work, so a unit is never torn in half. That pattern, with an interruptible event.wait(), is shown in full on the topic overview. The exception approach interrupts whatever line is running; the event approach lets the current unit finish. Choose per command.
What a handler may and may not do
Python runs signal handlers in the main thread, between bytecode instructions, at whatever point the main thread happened to be. That makes them precarious places for real work: a handler that takes a lock the interrupted code already holds deadlocks, and one that does network I/O can blow the grace period by itself.
Keep handlers to setting an event or raising an exception, and do the cleanup in ordinary code that the exception or flag leads to. Note also that handlers only ever run in the main thread: if the main thread is blocked joining a worker thread with no timeout, the handler waits too. Join with a timeout in a loop, or wait on an event, so signals are noticed.
Exit codes
A process killed by a signal has no exit code of its own; shells and supervisors report 128 + signal number. When you catch the signal and shut down cleanly, exit with the same value, so everything watching sees "stopped by SIGTERM" rather than "succeeded" or "crashed".
systemd treats 143 after a stop request as a clean exit when the unit sets SuccessExitStatus=143; Kubernetes records it as the container's exit code. Exiting 0 after being told to stop mid-task would falsely report that the work was completed.
When your CLI is PID 1
In a container started with docker run image mytool worker (or an ENTRYPOINT in exec form), your Python process is PID 1. The Linux kernel does not apply default signal actions to PID 1: a SIGTERM with no handler installed is simply ignored. The container then sits there for the full grace period and is killed with SIGKILL — every docker stop takes ten seconds and no cleanup ever runs. Installing a handler, as above, fixes it. So does running with docker run --init or tini as the entrypoint, which also reaps zombie child processes — worth doing if your CLI starts subprocesses.
Also check the entrypoint uses exec form (ENTRYPOINT ["mytool", "worker"]). Shell form (ENTRYPOINT mytool worker) wraps your process in /bin/sh -c, which receives the signal and does not forward it.
UX considerations
- Say why you stopped. "stopped by signal 15; nothing written" in the log tells an operator the process did not crash.
- Keep partial results consistent. Build into a temporary location and publish at the end, so a stop mid-way leaves the previous output intact rather than half of the new one. The pattern is in safe temporary files and directories.
- Bound your cleanup. If cleanup involves the network, put a timeout on it well under the shortest grace period. A courteous "job aborted" call to an API is not worth being
SIGKILLed for. - Stop child processes too. A
SIGTERMto your CLI does not reach children in other process groups. Forward it, as described in handling subprocess timeouts and exit codes. - Flush logs. Run with
PYTHONUNBUFFERED=1under supervisors so the last lines before shutdown actually reach the journal.
Testing the behaviour
Signal handling can only be tested convincingly with a real signal to a real process. Start the CLI as a subprocess, wait until it is working, send SIGTERM, and assert on the exit code, the message and the filesystem:
# tests/test_sigterm.py
import signal
import subprocess
import sys
import time
import pytest
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
def start_export(tmp_path):
out = tmp_path / "out"
proc = subprocess.Popen(
[sys.executable, "-m", "mytool.cli", "export", str(out), "--items", "200"],
stderr=subprocess.PIPE, text=True, env={"TMPDIR": str(tmp_path), "PATH": ""},
)
time.sleep(1.0) # let it get into the work loop
return proc, out
def test_sigterm_cleans_up_and_exits_143(tmp_path):
proc, out = start_export(tmp_path)
proc.send_signal(signal.SIGTERM)
_, err = proc.communicate(timeout=5)
assert proc.returncode == 143
assert "stopped by signal 15" in err
assert not out.exists() # nothing half-published
assert not list(tmp_path.glob("mytool-export-*")) # temp dir removed
def test_sigint_still_works(tmp_path):
proc, out = start_export(tmp_path)
proc.send_signal(signal.SIGINT)
proc.communicate(timeout=5)
assert proc.returncode == 130
assert not list(tmp_path.glob("mytool-export-*"))
Pointing TMPDIR at tmp_path lets the test see exactly which temporary directories the command created and prove they were removed. The one-second sleep is crude but reliable; for faster tests, have the command print a "started" line and wait for it on the pipe.
Conclusion
SIGTERM is how the world asks your CLI to stop, and by default Python does not listen. Convert it into an exception for sequential commands or a stop event for loops, keep handlers tiny, fit cleanup inside the shortest grace period your users face, exit with 128 + signal, and remember that as PID 1 in a container you get no default handling at all. One subprocess-based test per signal keeps it all honest.
Frequently asked questions
Can I just use atexit for cleanup?
atexit handlers run on normal interpreter exit, including after sys.exit() and unhandled exceptions — but not when the process is killed by an unhandled signal. With a SIGTERM handler that raises, atexit handlers do run; without one, they do not. Context managers and finally blocks near the work are easier to reason about.
What about Windows?
Windows has no real SIGTERM delivery between processes. Console programs receive CTRL_C_EVENT or CTRL_BREAK_EVENT (which Python maps to KeyboardInterrupt and SIGBREAK), and TerminateProcess — what most tools use to stop a process — cannot be caught. Handle SIGBREAK where available and rely on the same exception-based cleanup.
Does asyncio handle SIGTERM for me?
asyncio.run handles SIGINT only. Add loop.add_signal_handler(signal.SIGTERM, task.cancel) for the main task on POSIX to get the same cancellation-based shutdown, as covered in cancelling async tasks on Ctrl+C.
Should I ignore SIGHUP?
For interactive commands, treating SIGHUP (terminal closed) like SIGTERM is sensible — the user has gone. For daemons, SIGHUP conventionally means "reload configuration". Pick one meaning per command and document it.