Detecting when a Python script terminates allows you to perform essential cleanup tasks such as closing database connections, flushing buffers, saving application state, or writing final log entries. Python provides several mechanisms to handle different exit scenarios, and understanding which tool covers which situation is critical for writing robust applications.
In this guide, you will learn how to use the atexit module for normal termination, handle keyboard interrupts and system signals, combine multiple strategies for comprehensive coverage, and understand the limitations of each approach.
Using atexit for Normal Termination
The atexit module registers functions that run automatically when a script ends normally, whether it reaches the end of the file, calls sys.exit(), or encounters an unhandled exception:
import atexit
def cleanup():
print("Cleaning up resources...")
atexit.register(cleanup)
print("Script running...")
print("Script ending...")Output:
Script running...
Script ending...
Cleaning up resources...The cleanup() function is called automatically after the script finishes. You never need to call it explicitly.
Using the Decorator Syntax
For cleaner code, use the @atexit.register decorator directly on your cleanup functions:
import atexit
@atexit.register
def save_state():
print("Saving application state...")
@atexit.register
def close_connections():
print("Closing database connections...")
print("Application running...")Output:
Application running...
Closing database connections...
Saving application state...Passing Arguments to Cleanup Functions
When your cleanup function requires arguments, pass them as additional parameters to atexit.register():
import atexit
def save_file(filepath, data):
print(f"Saving {len(data)} items to {filepath}")
data = {"users": 42, "sessions": 15}
atexit.register(save_file, "backup.json", data)
print("Processing complete")Output:
Processing complete
Saving 2 items to backup.jsonThis is useful when your cleanup function needs references to resources that were created during the script's execution.
Handling Keyboard Interrupts and Signals
The atexit module handles normal exits and sys.exit(), but it does not run when the script is interrupted by Ctrl+C (SIGINT) or terminated by a system signal (SIGTERM). For these cases, use the signal module:
import signal
import sys
import atexit
def graceful_shutdown(signum, frame):
signal_name = signal.Signals(signum).name
print(f"\nReceived {signal_name}. Shutting down...")
sys.exit(0)
@atexit.register
def cleanup():
print("Final cleanup complete.")
signal.signal(signal.SIGINT, graceful_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)
print("Server running. Press Ctrl+C to stop...")
import time
while True:
time.sleep(1)Output when Ctrl+C is pressed:
Server running. Press Ctrl+C to stop...
Received SIGINT. Shutting down...
Final cleanup complete.Using try/finally for Local Resource Cleanup
For cleanup tasks scoped to a specific function or block of code, try/finally guarantees execution even when exceptions occur:
def process_data():
print("Opening resources...")
try:
print("Processing data...")
raise ValueError("Something went wrong")
finally:
print("Releasing local resources...")
try:
process_data()
except ValueError as e:
print(f"Caught error: {e}")Output:
Opening resources...
Processing data...
Releasing local resources...
Caught error: Something went wrongThe finally block runs regardless of whether an exception occurs, making it ideal for releasing file handles, network connections, or locks within a specific scope.
Combining atexit with Context Managers
For comprehensive resource management, combine atexit for global cleanup with context managers for local resources:
import atexit
class ApplicationState:
def __init__(self):
self.data = {}
atexit.register(self.save)
print("Application state initialized")
def save(self):
print(f"Saving {len(self.data)} items to disk...")
def update(self, key, value):
self.data[key] = value
state = ApplicationState()
state.update("user_count", 42)
with open("log.txt", "w") as log_file:
log_file.write("Processing started\n")
print("Log file written")
print("Script finishing...")Output:
Application state initialized
Log file written
Script finishing...
Saving 1 items to disk...A Common Mistake: Relying Solely on atexit for Signal Handling
A frequent error is assuming atexit handlers will run in all termination scenarios:
import atexit
@atexit.register
def critical_save():
print("Saving critical data...")
print("Running... press Ctrl+C")
import time
while True:
time.sleep(1)When you press Ctrl+C, the output is:
Running... press Ctrl+C
^CTraceback (most recent call last):
...
KeyboardInterruptThe critical_save function never runs. To fix this, add a signal handler that calls sys.exit():
import atexit
import signal
import sys
def handle_interrupt(signum, frame):
sys.exit(0)
signal.signal(signal.SIGINT, handle_interrupt)
@atexit.register
def critical_save():
print("Saving critical data...")
print("Running... press Ctrl+C")
import time
while True:
time.sleep(1)Now pressing Ctrl+C produces:
Running... press Ctrl+C
Saving critical data...Exit Scenario Coverage
Understanding which mechanism covers which exit scenario is essential for writing reliable cleanup code:
| Exit Scenario | atexit Runs |
try/finally Runs |
Signal Handler Runs |
|---|---|---|---|
| Normal script end | Yes | Yes | N/A |
sys.exit() |
Yes | Yes | N/A |
| Unhandled exception | Yes | Yes | N/A |
Ctrl+C (SIGINT) |
No | Yes | Yes |
kill (SIGTERM) |
No | No | Yes |
kill -9 (SIGKILL) |
No | No | No |
| Power failure | No | No | No |
Complete Example: Layered Cleanup Strategy
Here is a practical example that combines all three mechanisms for maximum coverage:
import atexit
import signal
import sys
resources = {"db_connected": True, "cache_dirty": True}
def handle_signal(signum, frame):
"""Convert signals into clean exits so atexit handlers run."""
signal_name = signal.Signals(signum).name
print(f"\nReceived {signal_name}")
sys.exit(0)
@atexit.register
def global_cleanup():
"""Runs on normal exit, sys.exit(), and unhandled exceptions."""
if resources["db_connected"]:
print("Closing database connection...")
resources["db_connected"] = False
if resources["cache_dirty"]:
print("Flushing cache to disk...")
resources["cache_dirty"] = False
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
def run():
try:
print("Application started")
print("Processing...")
finally:
print("Local resources released")
run()
print("Application finished normally")Output (normal exit):
Application started
Processing...
Local resources released
Application finished normally
Closing database connection...
Flushing cache to disk...Conclusion
-Use atexit for global cleanup tasks that should run when your script ends normally or via sys.exit().
- Combine it with signal handlers to catch interrupts like
Ctrl+Cand termination signals, making sure to callsys.exit()inside the handler so thatatexitfunctions still execute. - For resource management scoped to a specific function or block, use
try/finallyor context managers.
This layered approach ensures reliable cleanup across all recoverable exit scenarios while keeping your code organized and maintainable.