Beginner

Your Python script connects to a database, opens a temp file, and acquires a lock file. Then an unhandled exception crashes it. The temp file is left on disk, the lock is never released, and the next run refuses to start because the lock file still exists. This kind of “dirty exit” is one of the most frustrating classes of bugs to debug — and the atexit module is the standard library’s answer to it.

atexit lets you register cleanup functions that Python guarantees to call when the interpreter exits normally — whether the program finishes naturally, calls sys.exit(), or raises an unhandled exception. It is simpler than try/finally blocks because you register the cleanup once, anywhere in your code, and forget about it.

In this article we will cover registering cleanup functions with atexit.register(), passing arguments to cleanup functions, the execution order of multiple handlers, the limitations of atexit, and how it compares to try/finally and context managers. By the end you will know exactly when and how to use atexit for reliable resource cleanup.

Python atexit: Quick Example

# quick_atexit.py
import atexit

def cleanup():
    print("Cleanup: releasing resources.")

atexit.register(cleanup)

print("Script running...")
print("Script done.")

Output:

Script running...
Script done.
Cleanup: releasing resources.

The cleanup function runs after print("Script done.") — after the main script body finishes. You can register it at any point in your code, even before the resources it cleans up have been created. This is the core pattern: register early, clean up late.

What Is atexit and Why Use It?

The atexit module maintains a stack of registered functions. When the Python interpreter shuts down, it pops and calls each function in last-in-first-out order. This mirrors the natural teardown order of programs — the last thing you set up is usually the first thing you need to clean up.

ApproachProsCons
atexit.register()Simple, global, works with sys.exit()Does not run on SIGKILL or os._exit()
try/finallyPrecise scope, catches all exceptionsMust wrap every code path manually
Context managers (with)Pythonic, automatic, reusableMust use with block everywhere
Signal handlersHandles OS signals (SIGTERM, etc.)Unix-specific, more complex

atexit is not a replacement for context managers — it is a complement. Use context managers for local resource management and atexit for process-level cleanup that must happen regardless of where the exit originates.

Registering Handlers with Arguments

You often need to pass the resource you are cleaning up to the handler. atexit.register() accepts positional and keyword arguments that are forwarded to the function when it is called.

# atexit_with_args.py
import atexit
import tempfile
import os

def delete_temp_file(path: str) -> None:
    if os.path.exists(path):
        os.remove(path)
        print(f"Deleted temp file: {path}")
    else:
        print(f"Temp file already gone: {path}")

# Create temp file and register cleanup immediately
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tmp")
tmp.write(b"Temporary data")
tmp.close()

print(f"Created: {tmp.name}")

# Register the cleanup with the path as an argument
atexit.register(delete_temp_file, tmp.name)

print("Doing some work...")
# Imagine this raises an exception here -- cleanup still runs
print("Work done.")

Output:

Created: /tmp/tmpAbc123.tmp
Doing some work...
Work done.
Deleted temp file: /tmp/tmpAbc123.tmp

The function signature is atexit.register(func, *args, **kwargs). The args and kwargs are captured at registration time — they are not re-evaluated when the handler runs. This means you can safely register a cleanup with a path even before the file exists, as long as the handler checks existence before deleting.

atexit.register() -- the cleanup function that runs even when you forget to call it.
atexit.register() — the cleanup function that runs even when you forget to call it.

Execution Order of Multiple Handlers

Handlers are called in LIFO (last-in-first-out) order, like a stack. The most recently registered handler runs first. This mirrors the natural teardown sequence — if you open a database connection and then acquire a lock, you should release the lock before closing the connection.

# atexit_order.py
import atexit

def teardown_db():
    print("3. Database connection closed.")

def release_lock():
    print("2. Lock released.")

def cleanup_cache():
    print("1. Cache flushed.")

# Register in setup order
atexit.register(teardown_db)
atexit.register(release_lock)
atexit.register(cleanup_cache)

print("Application running...")

Output:

Application running...
1. Cache flushed.
2. Lock released.
3. Database connection closed.

Even though teardown_db was registered first, it runs last. This is the correct order: flush the cache (which may need the lock), release the lock, then close the database. Plan your registration order accordingly.

Unregistering Handlers

Sometimes you set up a resource, register its cleanup, but then explicitly clean it up mid-program. You do not want the atexit handler to run a second time at exit. Use atexit.unregister(func) to remove a previously registered handler.

# atexit_unregister.py
import atexit
import os

LOCK_FILE = "/tmp/myapp.lock"

def remove_lock():
    if os.path.exists(LOCK_FILE):
        os.remove(LOCK_FILE)
        print(f"Lock removed: {LOCK_FILE}")

# Create lock file
with open(LOCK_FILE, "w") as f:
    f.write(str(os.getpid()))
print(f"Lock created: {LOCK_FILE}")

atexit.register(remove_lock)

# ... do some work ...

# Mid-program explicit cleanup
remove_lock()
atexit.unregister(remove_lock)  # Don't run again at exit

print("Lock explicitly released. No double cleanup.")

Output:

Lock created: /tmp/myapp.lock
Lock removed: /tmp/myapp.lock
Lock explicitly released. No double cleanup.

atexit.unregister(func) removes all registrations of that function, even if it was registered multiple times. Note that unregister was added in Python 3.2 — if you need to support older versions, use a flag variable inside the handler instead.

atexit.unregister() -- for when you clean up early and want no double-tap.
atexit.unregister() — for when you clean up early and want no double-tap.

Limitations of atexit

Understanding when atexit does NOT run is as important as knowing when it does. Three situations bypass atexit entirely:

# atexit_limitations.py
import atexit
import os
import signal

def my_cleanup():
    print("Cleanup ran!")

atexit.register(my_cleanup)

# Case 1: os._exit() -- bypasses atexit entirely
# os._exit(0)  # Uncomment to test -- cleanup will NOT run

# Case 2: SIGKILL -- the OS terminates with no handler
# os.kill(os.getpid(), signal.SIGKILL)  # Uncomment -- cleanup will NOT run

# Case 3: Fatal interpreter errors (e.g., C extension segfault)
# -- atexit does not run

# Normal exit: atexit DOES run
# sys.exit() -- atexit runs
# Unhandled exception -- atexit runs (after printing traceback)
# Program ends naturally -- atexit runs

print("Program ending normally. atexit will run.")

Output:

Program ending normally. atexit will run.
Cleanup ran!

The three cases where atexit is bypassed: os._exit() (used by multiprocessing worker processes), SIGKILL (uncatchable), and fatal C-level errors. For Kubernetes deployments that send SIGTERM before SIGKILL, combine atexit with a SIGTERM handler (from the signal module) to cover all normal shutdown paths.

Real-Life Example: Application Bootstrap with Cleanup Stack

Here is a complete application bootstrap pattern that registers multiple cleanup handlers as resources come online.

# app_bootstrap.py
import atexit
import os
import json
import time
from pathlib import Path

class AppResources:
    """Manages application resources with atexit-based cleanup."""

    def __init__(self, app_name: str):
        self.app_name = app_name
        self.lock_path = Path(f"/tmp/{app_name}.lock")
        self.log_path = Path(f"/tmp/{app_name}.log")
        self.log_file = None
        self.start_time = time.time()

    def acquire_lock(self):
        if self.lock_path.exists():
            pid = self.lock_path.read_text().strip()
            raise RuntimeError(f"App already running (PID {pid}). Delete {self.lock_path} to force.")
        self.lock_path.write_text(str(os.getpid()))
        print(f"[{self.app_name}] Lock acquired: {self.lock_path}")
        atexit.register(self._release_lock)  # Register cleanup right away

    def open_log(self):
        self.log_file = open(self.log_path, "a")
        self.log_file.write(f"=== {self.app_name} started ===\n")
        print(f"[{self.app_name}] Log opened: {self.log_path}")
        atexit.register(self._close_log)  # Registered after lock, so runs before lock cleanup

    def _release_lock(self):
        if self.lock_path.exists():
            self.lock_path.unlink()
            uptime = time.time() - self.start_time
            print(f"[{self.app_name}] Lock released. Uptime: {uptime:.1f}s")

    def _close_log(self):
        if self.log_file and not self.log_file.closed:
            self.log_file.write(f"=== {self.app_name} stopped ===\n")
            self.log_file.close()
            print(f"[{self.app_name}] Log closed.")

def main():
    app = AppResources("myapp")
    app.acquire_lock()
    app.open_log()

    print("Application doing work...")
    time.sleep(0.1)

    # Write some log entries
    app.log_file.write("Processed 42 requests\n")
    print("Work complete.")
    # atexit handlers fire here automatically

if __name__ == "__main__":
    main()

Output:

[myapp] Lock acquired: /tmp/myapp.lock
[myapp] Log opened: /tmp/myapp.log
Application doing work...
Work complete.
[myapp] Log closed.
[myapp] Lock released. Uptime: 0.1s

Notice the LIFO order: _close_log runs before _release_lock, which is correct because we need to write the final log entry before closing the file, and we need the log closed before releasing the lock. By registering each cleanup immediately after acquiring each resource, you get the right teardown order automatically.

Last in, first out. Register in setup order, tear down in reverse.
Last in, first out. Register in setup order, tear down in reverse.

Frequently Asked Questions

Should I use atexit instead of try/finally?

No — use both, for different purposes. try/finally is best for local resource management within a function or code block. atexit is best for process-level cleanup that needs to run regardless of where in the code the exit happens. Context managers (with statements) are the cleanest way to handle local resource cleanup in modern Python.

What happens if an atexit handler raises an exception?

Python prints the exception traceback and continues calling the remaining handlers. No handler can prevent the other handlers from running. After all handlers have run, Python exits with code 1. This means you should write handlers defensively — check if the resource exists before accessing it, and wrap risky operations in their own try/except.

Do atexit handlers run in threads?

No. atexit handlers are registered globally and run in the main thread during interpreter shutdown. They do not run when individual threads finish — only when the whole process exits. If you need cleanup when a thread exits, use threading.Thread subclassing or a try/finally block inside the thread’s target function.

Does atexit work with multiprocessing?

Each process has its own atexit stack. Handlers registered in the main process do not run in child processes, and vice versa. Worker processes created by multiprocessing.Pool use os._exit() to terminate, which bypasses atexit entirely. Register cleanup in the main process for shared resources, and use multiprocessing.Pool‘s finalizer argument for worker-level cleanup.

Is the execution order of atexit handlers guaranteed?

Yes — handlers are always called in LIFO (last-in-first-out) order. The CPython implementation guarantees this. The guarantee holds across normal exits, sys.exit(), and unhandled exceptions. Plan your registration order with the teardown sequence in mind.

Conclusion

The atexit module provides a simple, reliable way to register cleanup functions at the process level. We covered registering handlers with and without arguments, understanding LIFO execution order, unregistering handlers with atexit.unregister(), and the limitations of atexit (os._exit, SIGKILL). The app bootstrap example demonstrates a production-ready pattern for managing multiple resources cleanly.

Extend the bootstrap pattern by combining it with a SIGTERM handler from the signal module — that covers graceful shutdown from process managers like systemd and Kubernetes, where atexit alone is not sufficient.

For the full API reference, see the Python atexit documentation.