How to Detect Script Exit in Python
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...
Registered functions execute in Last-In, First-Out (LIFO) order. The most recently registered function runs first. In the example above, close_connections was registered after save_state, so it runs before it.
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.json
This 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.")
# Handle Ctrl+C
signal.signal(signal.SIGINT, graceful_shutdown)
# Handle termination requests (e.g., from process managers)
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.
Calling sys.exit() inside your signal handler ensures that atexit functions still run after catching the signal. Without it, the atexit handlers would be skipped entirely.
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...")
# Simulate an error
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 wrong
The 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
# Global cleanup via atexit
state = ApplicationState()
state.update("user_count", 42)
# Local cleanup via context manager
with open("log.txt", "w") as log_file:
log_file.write("Processing started\n")
print("Log file written")
# File handle is automatically closed when the 'with' block ends
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):
...
KeyboardInterrupt
The 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 |
Nothing can intercept SIGKILL (kill -9) or sudden power loss. For critical data integrity, implement periodic saves or write-ahead logging rather than relying solely on exit handlers. If your application cannot afford to lose data, save state continuously rather than only at shutdown.
Complete Example: Layered Cleanup Strategy
Here is a practical example that combines all three mechanisms for maximum coverage:
import atexit
import signal
import sys
# Global state
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
# Register signal handlers
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
# Application logic with local cleanup
def run():
try:
print("Application started")
print("Processing...")
# ... application logic here ...
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.