Command-line tools and services often need to save state, close reports, remove temporary files, or record metrics when they terminate. The Python atexit module registers functions that run automatically during normal interpreter shutdown, reducing the chance that a final cleanup step is forgotten.
This guide covers register(), decorator usage, arguments, LIFO order, unregister(), and exception handling. It complements our articles about context managers, tempfile, tracebacks, faulthandler, and weak references.
Register a cleanup function
atexit.register() adds a callable to the shutdown list.
import atexit
def goodbye():
print("Application stopped")
atexit.register(goodbye)When the main module finishes normally or sys.exit() is called, the handler runs.
Use register as a decorator
Because register() returns the original function, it can be used as a decorator.
@atexit.register
def save_metrics():
print("Saving metrics")This form is direct for functions without arguments. For parameters, pass them in the registration call.
Pass arguments
def record_exit(name, status="ok"):
print(f"{name}: {status}")
atexit.register(
record_exit,
"processing",
status="finished",
)Arguments remain referenced until shutdown. Avoid capturing enormous object graphs or resources that may already be partially dismantled.
LIFO order
Handlers run in reverse registration order. If A, B, and C are registered in that order, execution is C, B, and A.
atexit.register(lambda: print("registered first"))
atexit.register(lambda: print("registered second"))
atexit.register(lambda: print("registered third"))The official atexit documentation explains that lower-level modules tend to load earlier and are therefore cleaned up later.
Normal termination
Handlers execute when the main module reaches its end or sys.exit() raises SystemExit.
import sys
atexit.register(lambda: print("cleanup"))
sys.exit(0)The selected status does not prevent cleanup. A handler should not silently obscure the intended final exit status.
Cases where handlers do not run
Atexit is not an absolute guarantee. Handlers are skipped when:
- the process receives a fatal signal not handled by Python;
- the interpreter encounters a fatal internal error;
os._exit()is used;- the operating system kills the process abruptly;
- the machine loses power.
Critical data needs transactional persistence during normal operation, not only at the end.
Signals and controlled shutdown
Applications that need to respond to SIGTERM or SIGINT should install handlers with signal and initiate a controlled exit.
import signal
import sys
def stop(signum, frame):
sys.exit(128 + signum)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)The signal handler should remain simple. Converting the signal into SystemExit allows the normal shutdown path to run atexit handlers.
Avoid os._exit()
os._exit() terminates immediately without ordinary flushing, finally blocks, atexit handlers, or other high-level cleanup.
It exists for specialized process-control scenarios. It should not be the normal way to leave an application.
Exceptions in handlers
If a handler raises an exception other than SystemExit, a traceback is printed and remaining handlers still get a chance to run.
After all handlers have run, the last exception is re-raised.
def fail():
raise RuntimeError("cleanup failed")
atexit.register(fail)Each step should decide which failures can be logged and tolerated without blocking unrelated cleanup.
A resilient handler
import logging
logger = logging.getLogger(__name__)
def close_report():
try:
generate_final_report()
except Exception:
logger.exception("Final report could not be generated")
atexit.register(close_report)Do not hide every error when failure should affect the process status. Distinguish diagnostics from shutdown correctness.
Registering the same function repeatedly
The same function and arguments may be registered more than once. Every occurrence is called.
def show(name):
print(name)
atexit.register(show, "A")
atexit.register(show, "B")Plugin systems can accidentally duplicate registrations. Protect initialization with explicit state.
Remove handlers with unregister()
atexit.unregister() removes every occurrence of a matching function.
atexit.unregister(close_report)Calling it for an unregistered function has no effect. Matching uses equality, not only object identity.
Equality implications
Callable objects may implement __eq__(). Several instances considered equal can be removed together.
class Action:
def __init__(self, name):
self.name = name
def __call__(self):
print(self.name)
def __eq__(self, other):
return isinstance(other, Action) and self.name == other.nameFor important handlers, prefer simple functions or keep clear references.
Do not modify registrations during cleanup
Registering or unregistering functions while handlers are being executed has undefined effects.
Build the handler list during startup. If ordering must be dynamic, register one coordinator that manages its own list.
A cleanup coordinator
actions = []
def add_action(function):
actions.append(function)
def run_actions():
for function in reversed(actions):
try:
function()
except Exception:
logger.exception("Cleanup action failed")
atexit.register(run_actions)This centralizes ordering, logging, and metrics while retaining the same abrupt-shutdown limitations.
Do not start threads
Since Python 3.12, starting a new thread from an atexit handler raises RuntimeError.
import threading
def incorrect():
thread = threading.Thread(target=work)
thread.start()The runtime may already be releasing thread state. Start and stop workers before entering the atexit phase.
Do not call fork()
Also since Python 3.12, calling os.fork() in a handler raises RuntimeError.
Creating processes while the interpreter is dismantling itself can cause races and crashes. Finish asynchronous shutdown earlier or delegate it to an external supervisor.
Existing worker threads
Atexit does not replace a shutdown protocol. The main thread should signal workers, wait for join(), and only then terminate.
stop_event.set()
for thread in threads:
thread.join(timeout=5)An atexit handler can record that a worker remained alive, but it should not create new infrastructure.
Prefer context managers
Local resources should be closed with with.
with open("output.txt", "w", encoding="utf-8") as file:
file.write("result")The resource is released as soon as it is no longer needed, including during exceptions. Atexit should be a final layer for process-wide resources.
Prefer try/finally when possible
When the application controls its main loop, try/finally makes cleanup order explicit.
start()
try:
run_loop()
finally:
stop()This pattern is easier to test. Atexit helps when initialization happens inside modules and no single shutdown point is available.
Temporary files
Objects from tempfile normally support context managers and automatic cleanup. Use atexit only as a fallback for process-wide temporary resources.
Do not assume deletion will always happen. Place temporary data in a secure location that can be cleaned on the next startup.
Persisting lightweight state
A classic example saves a counter.
from pathlib import Path
import atexit
path = Path("counter.txt")
counter = int(path.read_text()) if path.exists() else 0
def save():
temporary = path.with_suffix(".tmp")
temporary.write_text(str(counter), encoding="utf-8")
temporary.replace(path)
atexit.register(save)Temporary writing and replacement reduce partial files, but they do not guarantee execution during a crash.
Logging during shutdown
Handler ordering and module teardown can affect logging. Register the handler after logger configuration and keep destinations available.
Avoid relying on globals that other cleanup code may mutate. Capture direct references when registering.
Subinterpreters
Since Python 3.7, atexit registrations made by C extensions are local to the subinterpreter in which they were registered.
Embedded runtimes with multiple interpreters must install cleanup in the appropriate context.
Testing handlers
Do not terminate the main test process. Extract cleanup logic into ordinary functions and test those directly.
def save_state():
...
atexit.register(save_state)
def test_save_state(tmp_path):
save_state()For integration testing, start a subprocess and inspect files, output, and exit status after it terminates.
Subprocess integration test
import subprocess
import sys
result = subprocess.run(
[sys.executable, "test_app.py"],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "cleanup complete" in result.stdoutAdd scenarios for sys.exit(), unhandled exceptions, and signals converted into controlled exits.
Web applications
Servers and frameworks provide startup and shutdown hooks. Use the official lifecycle to close pools, queues, clients, and workers.
Atexit may provide a local fallback, but an orchestrator can terminate workers abruptly. Core cleanup belongs in the framework lifecycle.
Containers and Kubernetes
Containers receive SIGTERM and have a grace period before SIGKILL. PID 1 should handle the signal, stop accepting work, finish current operations, and exit normally.
Atexit runs only when that controlled path reaches Python shutdown before the deadline.
Common mistakes
- Using atexit as the only protection for critical data.
- Expecting it after
os._exit()or SIGKILL. - Starting a thread or process in a handler.
- Registering one function repeatedly by accident.
- Changing registrations during cleanup.
- Depending on logging that has already shut down.
- Using atexit instead of
with. - Running lengthy operations during shutdown.
Best practices
- Register short, idempotent handlers.
- Use LIFO order deliberately.
- Prefer context managers and
finally. - Do not start threads or fork.
- Protect writes with temporary files.
- Test logic directly and through subprocesses.
- Integrate signals and framework lifecycle.
- Plan recovery after abrupt termination.
Conclusion
The Python atexit module registers functions that run in reverse order during normal interpreter shutdown. It is useful for final metrics, lightweight persistence, and process-wide resources that lack a better closing point.
The guarantee is limited: fatal signals, os._exit(), crashes, and power loss skip handlers. With short idempotent functions, context managers, and an explicit shutdown protocol, atexit becomes a final organizational layer rather than a substitute for durability.






