Python's atexit module provides a clean way to guarantee that specific functions run when the interpreter shuts down. Introduced in Python 2.0 as a replacement for the older sys.exitfunc hook, it solved the fundamental limitation of sys.exitfunc: only one exit function could be registered at a time. The key function, atexit.register, works as both a regular function call and a decorator. When used with the @ syntax, it registers the decorated function for automatic execution at exit without changing the function itself. As the official Python documentation states, the function returns func, which makes it possible to use it as a decorator. This is a decorator that does not wrap or modify behavior during normal calls -- it only schedules the function for one final call when the program ends. This article covers both usage forms, the execution order, when handlers do and do not fire, exception behavior during shutdown, subinterpreter scoping, and practical cleanup patterns -- all verified against the CPython source and official documentation.
The atexit module is part of Python's standard library and requires a single import: import atexit. It maintains an internal list of callable objects. When the interpreter begins its shutdown sequence -- whether because the script reached its end, sys.exit() was called, or an unhandled exception propagated to the top level -- Python iterates through that list and calls each registered function. The functions receive no arguments about why the interpreter is exiting. They simply run. Since Python 3.7, when used with C-API subinterpreters, registered functions are local to the interpreter they were registered in, meaning an exit handler registered in one subinterpreter will not run in another.
The Two Ways to Register Exit Handlers
atexit.register can be called as a regular function or used as a decorator. Both forms add the target function to the exit handler list. The decorator form is cleaner when the function takes no arguments. The function-call form is necessary when you need to pass arguments to the handler.
Form 1: Decorator Syntax
import atexit
@atexit.register
def shutdown_message():
print("[EXIT] Interpreter is shutting down")
print("[MAIN] Program is running")
print("[MAIN] Program is ending")
# Output when script finishes:
# [MAIN] Program is running
# [MAIN] Program is ending
# [EXIT] Interpreter is shutting down
The @atexit.register decorator registers shutdown_message as an exit handler the moment Python processes the function definition. The function runs normally if you call it explicitly during the program, and it also runs one final time when the interpreter exits. The decorator returns the original function unchanged, so shutdown_message remains a regular callable.
Form 2: Function Call Syntax
import atexit
def save_state(filepath: str, data: dict) -> None:
print(f"[EXIT] Saving state to {filepath}")
with open(filepath, "w") as f:
f.write(str(data))
# Register with arguments -- decorator form cannot do this
atexit.register(save_state, "/tmp/app_state.txt", {"session": "active"})
print("[MAIN] Application running")
# Output when script finishes:
# [MAIN] Application running
# [EXIT] Saving state to /tmp/app_state.txt
The function-call form passes additional positional and keyword arguments that will be forwarded to save_state when it is called at exit. This is necessary because the decorator form (with @) provides no mechanism to supply arguments -- it registers the function to be called with zero arguments.
Unlike typical decorators that wrap a function to modify its behavior on every call, @atexit.register does not alter the function's behavior at all. It only adds the function to the exit handler list. The function works identically whether or not it is registered.
How do you pass arguments to an atexit handler?
LIFO Execution Order
Exit handlers run in LIFO (last in, first out) order. The function registered last runs first. The Python documentation explains the reasoning: lower-level modules are typically imported before higher-level ones, so their cleanup should happen later in the shutdown process.
import atexit
@atexit.register
def first_registered():
print("[EXIT] First registered -- runs LAST")
@atexit.register
def second_registered():
print("[EXIT] Second registered -- runs SECOND")
@atexit.register
def third_registered():
print("[EXIT] Third registered -- runs FIRST")
print("[MAIN] All handlers registered")
# Output:
# [MAIN] All handlers registered
# [EXIT] Third registered -- runs FIRST
# [EXIT] Second registered -- runs SECOND
# [EXIT] First registered -- runs LAST
The reversal is consistent and predictable. If your cleanup functions have dependencies -- for example, a log file that should be flushed before the logger is closed -- register the logger shutdown first and the flush second. The flush will run before the shutdown because it was registered later.
What happens to a function after you decorate it with @atexit.register?
When Handlers Fire (and When They Do Not)
Exit handlers run during normal interpreter termination. This includes several scenarios that might not be obvious:
| Scenario | Handlers Fire? |
|---|---|
| Script reaches its natural end | Yes |
sys.exit() is called | Yes |
| An unhandled exception propagates (after traceback prints) | Yes |
quit() or exit() in interactive mode | Yes |
KeyboardInterrupt (Ctrl+C) -- default SIGINT handler raises the exception, triggering normal shutdown | Yes |
os._exit() is called | No |
Process killed by SIGKILL (or any signal not handled by Python) | No |
| Python fatal internal error | No |
Script reaches its natural end
sys.exit() is called
sys.exit() raises SystemExit, which triggers normal interpreter shutdown. All atexit handlers fire before the process terminates.An unhandled exception propagates
quit() or exit() in interactive mode
quit() and exit() raise SystemExit internally, following the same path as sys.exit(). Handlers fire normally.KeyboardInterrupt (Ctrl+C)
Ctrl+C into a KeyboardInterrupt exception. If it propagates unhandled, it triggers normal shutdown and atexit handlers fire.os._exit() is called
Process killed by SIGKILL
SIGKILL and any signal not handled by Python terminate the process at the OS level. Python never gets the chance to run its shutdown sequence.Python fatal internal error
The critical case to understand is os._exit(). This function terminates the process immediately without running cleanup handlers, flushing stdio buffers, or calling atexit functions. It bypasses Python's normal shutdown sequence entirely. It is commonly used in child processes after os.fork() to avoid running the parent's cleanup handlers a second time -- since the forked child inherits the parent's registered handler list, calling sys.exit() in the child would execute all the parent's handlers in the child process as well. The KeyboardInterrupt row in the table is worth explaining further: Python's default SIGINT handler translates Ctrl+C into a KeyboardInterrupt exception. If this exception propagates unhandled, it triggers normal shutdown and atexit handlers fire. However, if you replace the default SIGINT handler using the signal module and your custom handler terminates the process via os._exit(), handlers will not fire.
The official Python documentation specifies three cases where registered handlers do not run: the process is killed by a signal Python does not handle, a fatal internal error occurs within the interpreter, or the program calls os._exit(). If your program uses os.fork(), be especially cautious -- child processes that call os._exit() will not run the parent's registered handlers.
Exception Behavior During Shutdown
What happens when an exit handler itself raises an exception? The Python documentation specifies this behavior precisely: when an exit handler raises an exception, Python prints the traceback (suppressing it only for SystemExit), saves the exception information, and continues running the remaining handlers. Once every handler has had its chance to run, Python re-raises the last exception that occurred.
This means a single failing handler does not prevent others from executing. Every registered handler gets its chance to run, even if an earlier handler raised an exception. The exception suppression rule for SystemExit is notable: if a handler raises SystemExit, the traceback is suppressed because SystemExit is treated as a normal exit signal, not an error.
import atexit
@atexit.register
def handler_a():
print("[EXIT] Handler A ran successfully")
@atexit.register
def handler_b():
raise ValueError("Something went wrong in B")
@atexit.register
def handler_c():
print("[EXIT] Handler C ran successfully")
print("[MAIN] Program ending")
# Output:
# [MAIN] Program ending
# [EXIT] Handler C ran successfully
# Error in atexit._run_exitfuncs:
# Traceback (most recent call last):
# File "example.py", line 11, in handler_b
# raise ValueError("Something went wrong in B")
# ValueError: Something went wrong in B
# [EXIT] Handler A ran successfully
Handler C runs first (LIFO), then handler B raises a ValueError. Python prints the traceback and continues. Handler A still runs. After all handlers complete, the ValueError from handler B is re-raised as the final exception.
The Python documentation also warns that the behavior of registering or unregistering functions from within a cleanup function is undefined. Do not call atexit.register() or atexit.unregister() from inside an exit handler. The behavior is not guaranteed across Python versions.
Canceling Registration with unregister()
atexit.unregister(func) removes all registrations of func from the exit handler list. It silently does nothing if func was never registered. This is useful when a cleanup function becomes unnecessary -- for example, when a resource is already cleaned up manually during the program's normal execution:
import atexit
def close_connection():
print("[EXIT] Closing database connection")
# Register the cleanup
atexit.register(close_connection)
# Later in the program, the connection is closed manually
print("[MAIN] Manually closing connection")
# ... connection.close() would go here ...
# No need for the exit handler anymore
atexit.unregister(close_connection)
print("[MAIN] Program ending")
# Output:
# [MAIN] Manually closing connection
# [MAIN] Program ending
# (close_connection does NOT run at exit)
unregister uses equality comparison (==), not identity (is). The Python documentation confirms that "equality comparisons are used internally during unregistration," meaning function references do not need to be the same object. Examining the CPython source code (atexitmodule.c) confirms this is implemented via PyObject_RichCompareBool(func, to_compare, Py_EQ). This means if the same function was registered multiple times, all registrations are removed by a single unregister call.
You register cleanup() three times with atexit.register(cleanup). Then you call atexit.unregister(cleanup) once. How many times does cleanup run at exit?
Practical Patterns
Pattern 1: Temporary File Cleanup
import atexit
import tempfile
from pathlib import Path
_temp_files: list[Path] = []
def create_temp_file(suffix: str = ".tmp") -> Path:
"""Create a temp file and track it for cleanup at exit."""
fd, raw_path = tempfile.mkstemp(suffix=suffix)
import os
os.close(fd) # Close the raw file descriptor immediately
path = Path(raw_path)
_temp_files.append(path)
return path
@atexit.register
def cleanup_temp_files() -> None:
"""Remove all temp files created during this session."""
for path in _temp_files:
path.unlink(missing_ok=True) # Python 3.8+: no error if already deleted
print(f"[CLEANUP] Removed {path}")
# Usage
tmp1 = create_temp_file(suffix=".log")
tmp2 = create_temp_file(suffix=".cache")
print(f"[MAIN] Created: {tmp1}")
print(f"[MAIN] Created: {tmp2}")
print("[MAIN] Program ending")
# Output:
# [MAIN] Created: /tmp/tmpXXXXXX.log
# [MAIN] Created: /tmp/tmpXXXXXX.cache
# [MAIN] Program ending
# [CLEANUP] Removed /tmp/tmpXXXXXX.log
# [CLEANUP] Removed /tmp/tmpXXXXXX.cache
The cleanup_temp_files function is registered the moment it is defined. Throughout the program's lifetime, temp files accumulate in the _temp_files list. When the interpreter exits, the handler iterates through the list and removes each file. The missing_ok=True parameter (available since Python 3.8) silently handles files that were already deleted by other code -- this is cleaner than wrapping each deletion in a try/except FileNotFoundError.
Pattern 2: Persisting In-Memory State
import atexit
import json
from pathlib import Path
_counter_path = Path("request_counter.json")
try:
_counters: dict[str, int] = json.loads(_counter_path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
_counters = {}
def record_request(endpoint: str) -> None:
"""Increment the request counter for an endpoint."""
_counters[endpoint] = _counters.get(endpoint, 0) + 1
@atexit.register
def save_counters() -> None:
"""Persist counters to disk when the program exits."""
_counter_path.write_text(json.dumps(_counters, indent=2))
print(f"[EXIT] Saved {len(_counters)} counters to {_counter_path}")
# Simulate some requests
record_request("/api/users")
record_request("/api/users")
record_request("/api/health")
print(f"[MAIN] Current counters: {_counters}")
# Output:
# [MAIN] Current counters: {'/api/users': 2, '/api/health': 1}
# [EXIT] Saved 2 counters to request_counter.json
This pattern loads state from a file at import time and saves it back automatically at exit. The program never needs to call save_counters() explicitly. If the program crashes with an unhandled exception, the counters are still saved because atexit handlers run after the traceback prints.
Pattern 3: Printing a Summary Report
import atexit
import time
_start_time = time.time()
_operations = 0
def track_operation() -> None:
global _operations
_operations += 1
@atexit.register
def print_summary():
elapsed = time.time() - _start_time
rate = _operations / elapsed if elapsed > 0 else 0
print(f"\n--- Session Summary ---")
print(f"Duration: {elapsed:.2f} seconds")
print(f"Operations: {_operations}")
print(f"Rate: {rate:.1f} ops/sec")
# Simulate work
for _ in range(1000):
track_operation()
print("[MAIN] Work complete")
# Output:
# [MAIN] Work complete
#
# --- Session Summary ---
# Duration: 0.00 seconds
# Operations: 1000
# Rate: 1523809.5 ops/sec
The summary report runs after all program output, providing a final accounting of what happened during the session without the main code needing to call any reporting function.
Pattern 4: Idempotent Cleanup with a Guard Flag
A common production problem: a cleanup function that can be called both manually (during normal flow) and automatically (at exit). If the resource is already cleaned up manually, the atexit handler should detect that and skip its work. The naive approach -- calling unregister() after manual cleanup -- works, but a guard flag pattern is more robust when multiple code paths might trigger cleanup:
import atexit
class ConnectionPool:
def __init__(self, size: int) -> None:
self._connections = [self._create_conn() for _ in range(size)]
self._drained = False
atexit.register(self._drain_at_exit)
def _create_conn(self) -> dict[str, str]:
return {"status": "open"} # Placeholder
def drain(self) -> None:
"""Manually drain all connections. Safe to call multiple times."""
if self._drained:
return
for conn in self._connections:
conn["status"] = "closed"
self._connections.clear()
self._drained = True
print("[POOL] Drained manually")
def _drain_at_exit(self) -> None:
"""Called by atexit. Only drains if not already done."""
if self._drained:
print("[POOL] Already drained -- skipping")
return
self.drain()
print("[POOL] Drained at exit")
pool = ConnectionPool(5)
# Scenario A: Manual drain before exit
pool.drain()
# At exit, _drain_at_exit detects self._drained is True and skips
# Scenario B: No manual drain
# At exit, _drain_at_exit runs drain() automatically
The guard flag (self._drained) makes the handler idempotent. This pattern is critical in long-running services where shutdown can be initiated from multiple sources: a signal handler, a health check failure, a graceful shutdown endpoint, or the interpreter itself. The handler does not need to know which path triggered cleanup -- it checks the flag and acts accordingly. This is more resilient than unregister() because it handles the case where the manual cleanup and the atexit registration happen in different modules that do not coordinate.
Pattern 5: Combining atexit with Signal Handling for SIGTERM
Containers and process managers (Docker, systemd, Kubernetes) terminate processes with SIGTERM, not SIGINT. By default, Python does not translate SIGTERM into an exception the way it translates SIGINT into KeyboardInterrupt. The default SIGTERM handler terminates the process, and atexit handlers do run. However, if you need to perform additional logic before the atexit handlers fire -- logging the signal, setting a flag for graceful drain, or triggering a flush -- you need a custom signal handler that cooperates with atexit:
import atexit
import signal
import sys
_shutdown_reason = "normal"
def handle_sigterm(signum: int, frame) -> None:
"""Translate SIGTERM into a clean SystemExit so atexit handlers fire."""
global _shutdown_reason
_shutdown_reason = "SIGTERM"
print(f"[SIGNAL] Received {signal.Signals(signum).name}, initiating shutdown")
sys.exit(0) # Raises SystemExit -> triggers atexit handlers
signal.signal(signal.SIGTERM, handle_sigterm)
@atexit.register
def final_cleanup() -> None:
print(f"[EXIT] Cleaning up (reason: {_shutdown_reason})")
# Flush buffers, close connections, persist state...
print("[MAIN] Running -- send SIGTERM to trigger graceful shutdown")
# If SIGTERM arrives:
# [SIGNAL] Received SIGTERM, initiating shutdown
# [EXIT] Cleaning up (reason: SIGTERM)
# If script ends normally:
# [EXIT] Cleaning up (reason: normal)
The key insight is sys.exit(0) inside the signal handler. This raises SystemExit, which Python treats as a normal exit, causing all atexit handlers to fire. Without this, a bare return from the signal handler would resume execution at whatever point the signal interrupted, and the program would continue running. This pattern is essential for any Python process that runs inside a container, where SIGTERM is the standard shutdown signal and you need cleanup to happen reliably.
Keep exit handlers short, focused, and defensive. Avoid complex logic, network calls, or anything that depends on resources that may have already been cleaned up during shutdown. If a handler fails, it should fail silently or log to stderr rather than raising exceptions that obscure the program's original output.
atexit vs try/finally and Context Managers
A natural question: why use atexit when Python already has try/finally blocks and context managers (with statements) for cleanup? The answer is about scope and guarantees. Each tool solves a different cleanup problem, and understanding when to reach for which one is a sign of fluency with Python's resource management model.
Guarantees cleanup runs when a specific block of code ends, whether normally or via exception. Scoped to a single code block. The cleanup code must be in the same function or call stack as the resource acquisition. If the resource is created in module-level code across multiple files, try/finally cannot help.
A structured form of try/finally. The __exit__ method runs when the with block ends. Ideal for resources with clear open/close lifetimes: files, locks, database connections. Requires the resource to be used within a with block, which is not always possible for program-wide state.
Guarantees cleanup runs when the entire interpreter shuts down, regardless of where or how the program ends. Not scoped to any block -- the handler runs at the very end. Ideal for program-wide resources: temp file registries, global state persistence, summary reports, and shared connection pools that live for the entire process lifetime.
The decision framework is straightforward: if the resource has a clear scope (opened in a function, used, then closed), use a context manager. If the resource lives for the entire program and needs cleanup at the very end regardless of exit path, use atexit. They are complementary, not competing.
Where atexit Sits in the Shutdown Sequence
Understanding when atexit handlers run requires a mental model of the Python shutdown sequence. The interpreter does not simply stop -- it follows a specific order of operations that is part of Python's runtime services layer. Knowing this order helps you predict what state is still available when your handler runs and what has already been torn down.
When a Python program ends normally, the shutdown proceeds in this order:
- Main module finishes execution (or
sys.exit()raisesSystemExit, or an unhandled exception propagates). - atexit handlers run in LIFO order. At this point, all modules are still imported and accessible. Global variables still hold their values. The garbage collector is still active. This is the safest point for cleanup code.
- The garbage collector runs its final collection. Objects with
__del__methods are finalized. Module globals start being set toNone. - Module dictionaries are cleared. If your code tries to access a module-level name after this point, it may find
Noneinstead of the expected value. - The interpreter deallocates remaining objects and exits the process.
The critical insight is step 2: atexit handlers run before the garbage collector's final pass and before module globals are cleared. This means your handler can safely access imported modules, global variables, and open file handles. If you try to do the same work in a __del__ method (step 3), you risk finding that the modules your code depends on have already been torn down -- a common source of the infamous AttributeError: 'NoneType' object has no attribute ... error during shutdown.
This is why the Python documentation recommends atexit over __del__ for cleanup. The module provides a straightforward way to schedule functions for normal program termination. By the time __del__ runs, the modules and globals it depends on may have already been set to None.
At what point in the Python shutdown sequence do atexit handlers run?
Common Pitfalls and Edge Cases
Starting New Threads During Shutdown
The Python documentation warns against starting new threads or calling os.fork() from within a registered exit function. During shutdown, the main runtime thread is actively freeing thread states, creating a race condition if new threads or child processes attempt to use that state. In Python 3.12, this became a hard error: attempting to start a new thread during interpreter shutdown raises RuntimeError with the message "can't create new thread at interpreter shutdown." This affected real-world libraries including pymongo, Sentry, and OpenTelemetry, which had atexit handlers that tried to spawn threads for flush operations.
The Decorator Returns the Original Function
Because @atexit.register returns the original function unchanged, the decorated function remains fully callable during normal program execution. This is different from typical decorators that return a wrapper. However, this also means there is no built-in way to tell at runtime whether a function is registered as an exit handler. The registration is invisible to introspection tools like inspect.
Registering the Same Function Multiple Times
import atexit
def cleanup():
print("[EXIT] cleanup ran")
atexit.register(cleanup)
atexit.register(cleanup)
atexit.register(cleanup)
# cleanup runs THREE times at exit, once for each registration
# Output:
# [EXIT] cleanup ran
# [EXIT] cleanup ran
# [EXIT] cleanup ran
Each call to atexit.register() adds a separate entry to the handler list. The module does not deduplicate. If you register the same function three times, it runs three times at exit. Use atexit.unregister() to remove all occurrences if this is not the intended behavior.
Interaction with sys.exit() Exit Codes
Calling sys.exit(n) raises SystemExit, which triggers the normal shutdown sequence including atexit handlers. The exit code is preserved -- handlers cannot change it. If a handler raises a different SystemExit with a different code, the last one raised wins, but this is a fragile pattern to rely on.
Check Your Understanding
1. Three functions -- A, B, and C -- are registered with atexit.register() in that order. In what order do they execute at exit?
2. You register a function with @atexit.register, and your program is terminated by os._exit(0). What happens?
3. An atexit handler raises a ValueError. What happens to the remaining handlers?
Spot the Bug
This code is supposed to register a cleanup handler that removes a temp file at exit. It has a subtle bug. Can you find it?
import atexit
import os
def make_temp_handler(filepath):
@atexit.register
def cleanup():
os.unlink(filepath)
print(f"Removed {filepath}")
for name in ["cache.tmp", "session.tmp", "lock.tmp"]:
make_temp_handler(f"/tmp/{name}")What is wrong with this code?
Frequently Asked Questions
The decorator form (@atexit.register) registers a zero-argument function at the moment Python processes the function definition. The function-call form (atexit.register(func, *args, **kwargs)) can store positional and keyword arguments that get forwarded to the handler when it runs at exit. Both forms add the function to the same internal handler list and both return the original function unchanged.
Handlers run in LIFO (last in, first out) order. The function registered last runs first. The Python documentation explains that lower-level modules are typically imported before higher-level ones, so their cleanup should happen later in the shutdown process.
No. os._exit() terminates the process immediately without running atexit cleanup handlers, flushing stdio buffers, or executing finally blocks. It bypasses Python's normal shutdown sequence entirely.
Yes. After an unhandled exception's traceback prints, Python still performs its normal shutdown sequence, which includes calling all registered atexit handlers. Handlers also fire after sys.exit(), after Ctrl+C (which raises KeyboardInterrupt), and when a script reaches its natural end.
Python prints the traceback to stderr and continues running the remaining handlers. A single failing handler does not prevent others from executing. After all handlers have had a chance to run, the last exception to be raised is re-raised. If a handler raises SystemExit, its traceback is suppressed.
The unregister() function uses equality comparison (==), not identity (is). The CPython source code confirms this via PyObject_RichCompareBool with Py_EQ. If the same function was registered multiple times, a single unregister() call removes all occurrences.
Key Takeaways
atexit.registerworks as both a function call and a decorator. The decorator form (@atexit.register) registers zero-argument functions at definition time. The function-call form (atexit.register(func, *args)) can pass arguments to the handler. Both return the original function unchanged.- Handlers run in LIFO order. The last function registered runs first. The Python documentation explains this ensures higher-level cleanup executes before the lower-level cleanup it depends on.
- Handlers fire on normal termination. This includes script completion,
sys.exit(), unhandled exceptions (after the traceback prints), andCtrl+C(which raisesKeyboardInterrupt). They do not fire onos._exit(),SIGKILL, or fatal internal errors. - Failing handlers do not block other handlers. If an exit handler raises an exception, the traceback prints and the remaining handlers still run. After all handlers complete, the last exception raised is re-raised.
SystemExitsuppresses its own traceback. atexit.unregister(func)removes all registrations using equality comparison. The CPython source confirms it uses==, notis. A singleunregister()call removes every occurrence of the function from the handler list.- Do not register or unregister from within a handler. The Python documentation states this behavior is undefined. Do not start new threads from handlers either -- Python 3.12+ raises
RuntimeErrorif you try. - Exit handlers should be simple and defensive. The interpreter is shutting down when they run. Resources may already be partially cleaned up. Handlers should catch their own exceptions and avoid operations that depend on the full runtime being available.
The atexit.register decorator is unlike any other decorator in Python's standard library. It does not modify, wrap, or replace the function. It does not change what happens when you call the function during normal execution. Its only effect is to add the function to a list of handlers that Python will call once, automatically, when the interpreter's work is done. The module has been part of the standard library since Python 2.0, replacing the single-function sys.exitfunc hook with a proper stack that supports multiple modules registering independent cleanup. This makes it the ideal tool for cleanup that must happen regardless of how the program ends: saving state, closing resources, removing temporary files, and printing final summaries.
Sources
- Python 3.14 Documentation: atexit -- Exit handlers
- CPython Source: Modules/atexitmodule.c -- C implementation confirming equality-based unregistration via
PyObject_RichCompareBool - CPython Issue #113964 -- Python 3.12 regression: "can't create new thread at interpreter shutdown" from atexit handlers
- CPython Source: Doc/library/atexit.rst -- reStructuredText source for the atexit documentation