python tutorials

Clean Exits: Managing Python Script Shutdowns with Signal Handlers

The Problem with Abrupt Terminations

When you run a long-lived Python script—like a data scraper or a local background worker—the most common way to stop it is by pressing Ctrl+C. This sends a SIGINT (Signal Interrupt) to your process. By default, Python raises a KeyboardInterrupt exception. If your code isn't wrapped in a massive try-except block, it dies instantly. This leaves database connections open, file buffers un-flushed, and temporary files littering your storage.

Using the Signal Module for Professional Cleanup

The signal module allows you to define custom behavior when the operating system sends a signal to your script. This is the standard way to implement a "Graceful Shutdown." Instead of a crash, your script catches the signal, finishes the current processing unit, and closes resources properly. This is particularly vital for scripts running in Docker containers, where SIGTERM is the default stop signal.

import signal
import time
import sys

def graceful_shutdown(sig, frame):
    print("\n[!] Shutdown signal received. Cleaning up resources...")
    # This is where you would close DB sessions or save checkpoints
    sys.exit(0)

# Register the signal handler for SIGINT (Ctrl+C)
signal.signal(signal.SIGINT, graceful_shutdown)

print("Worker started. Press Ctrl+C to exit safely.")
while True:
    time.sleep(1)
    print("Processing batch...")

The atexit Module: Your Safety Net

While signal is great for external interrupts, what if your script simply finishes its task or crashes due to a logic error? The atexit module provides a way to register cleanup functions that run when a program closes, regardless of why it ended. It is a cleaner alternative to placing cleanup logic at the bottom of your main file.

import atexit

@atexit.register
def final_cleanup():
    print("Cleanup: Releasing file locks and clearing cache.")

def run_app():
    print("Application logic is executing...")
    # If an error happens here, final_cleanup still runs

if __name__ == \"__main__\":
    run_app()

A Practical Example: The Robust Data Worker

Let's combine these patterns into a class-based worker. This approach ensures that even if you kill the process manually or it finishes naturally, the state is preserved. This is a common pattern in high-reliability backend systems.

import signal
import sys
import time

class DataWorker:
    def __init__(self):
        self.active = True
        # Catch both Ctrl+C and termination signals
        signal.signal(signal.SIGINT, self.handle_exit)
        signal.signal(signal.SIGTERM, self.handle_exit)

    def handle_exit(self, sig, frame):
        print(f\"\\nSignal {sig} received. Wrapping up the current task...\")
        self.active = False

    def run(self):
        while self.active:
            print(\"Syncing data to remote server...\")
            time.sleep(3)
        print(\"All tasks finished. System offline.\")

if __name__ == \"__main__\":
    worker = DataWorker()
    worker.run()

By using these patterns, you move from writing fragile scripts to robust, production-ready tools. You ensure that your applications respect the operating system's lifecycle and protect the integrity of your data, even in the event of an unexpected termination.