skip to content

What does atexit.register guarantee about when and in what order your callback runs?

level: juniorimportance: should knowfreq 45%

answer

  1. Cleanup queued for the way out
  2. Order is a stack, not a queue
  3. Hard exits never look at the list
  4. register returns the function it took
  5. unregister removes every registration

basics

~20 s

atexit.register queues a callable to run during normal interpreter shutdown, and the queued callables run last-registered-first. Nothing runs after a hard exit: os._exit, an unhandled fatal signal or an interpreter crash skips every registered handler.

solid answer

~40 s

`atexit.register(func, *args, **kwargs)` appends a callable to an interpreter-wide list and returns `func`, so it doubles as a decorator; `atexit.unregister(func)` removes every registration of that callable. During normal shutdown CPython walks the list in reverse registration order — LIFO — so something registered late by a module imported late is torn down before something registered early, which is usually the order dependencies were built in. The guarantee covers only *normal* termination: the main module finishing, or `sys.exit`. `os._exit`, a signal such as SIGTERM with no Python handler installed, SIGKILL, or a fatal interpreter error end the process without touching the list. On 3.14 an exception inside a handler is reported through `sys.unraisablehook`, the remaining handlers still run, and the process exit status is unaffected.

code

python · 13 lines
python
import atexit

@atexit.register
def first():
    print("registered first, runs last")

def second():
    print("registered second, runs first")

atexit.register(second)
atexit.register(print, "queued then removed")
atexit.unregister(print)
print("main body done")

go deeper

for a junior

Be ready to state that registered callbacks run only on normal shutdown and in reverse registration order, and to name one ending that skips them entirely, such as a process killed by a signal.

for a middle

Explain the mechanics: registration appends an entry with its own arguments, the function is returned so it works as a decorator, unregister removes every copy, and the interpreter walks the list LIFO during finalization.

for a senior

Show the operational judgment: shutdown callbacks are best effort, so anything that must survive is written incrementally, and a handler that blocks converts a clean stop into a hang that operators end with an uncatchable signal.

for a principal

Own the policy: decide where in a service's lifecycle cleanup belongs, whether shutdown work should be explicit and observable rather than hidden in a module import, and what the system does when cleanup never runs at all.

### What the module actually is `atexit` is a very small standard-library module whose entire job is to hold a list of callables that CPython invokes while it is finalizing the interpreter. `atexit.register(func, *args, **kwargs)` appends an entry — the function plus the positional and keyword arguments it should be called with — and returns `func` unchanged, which is why it reads naturally as a decorator: ```python import atexit @atexit.register def write_checkpoint(): ... ``` `atexit.unregister(func)` removes *every* entry whose callable is `func`; registering the same function three times and unregistering once removes all three, and unregistering something that was never registered is silently fine. ### The order: last in, first out Handlers run in reverse order of registration. The reasoning is the same as for a stack of nested resources: whatever was set up last usually depends on what was set up first, so it must be torn down first. In practice registration order tends to follow import order, so a late-imported subsystem's flush runs before the logging shutdown that it wants to log through. ```python import atexit atexit.register(print, "runs second") atexit.register(print, "runs first") ``` That ordering is a real guarantee, not an implementation accident, and interviewers ask about it because the naive answer — "in the order I registered them" — is the wrong half of a coin flip. ### The much weaker guarantee: only *normal* termination The module's own docstring says it plainly: the functions run "upon normal program termination". Normal means the main module finishes, or `sys.exit` unwinds the stack in the usual way. Every abnormal ending bypasses the list entirely: * **`os._exit(status)`** calls the C `_exit` directly. The process disappears without unwinding, without flushing buffered stdio, and without running a single handler. That is not a bug — it is exactly why `os._exit` exists, for instance in a forked child that has inherited the parent's handler list and must not run the parent's cleanups a second time. * **A signal with no Python-level handler.** A default SIGTERM or SIGHUP terminates the process at the OS level. Only SIGINT has a default Python handler (it raises `KeyboardInterrupt`, which unwinds normally and therefore *does* reach the handlers). * **SIGKILL**, which cannot be caught by anything, ever. * **A fatal interpreter error** — a segmentation fault in a C extension, an unrecoverable memory error, or `Py_FatalError`. The practical consequence is a design rule rather than a trivia point: an `atexit` handler is a *best-effort* convenience, never a durability mechanism. State that must survive the process must be written as it is produced, or written to a place that can be repaired on the next start. If losing what the handler would have written is unacceptable, the handler is the wrong tool. ### What happens when a handler itself fails On CPython 3.14, an exception raised inside a handler is routed to `sys.unraisablehook`. The default hook prints a traceback tagged as an ignored exception in an atexit callback; the remaining handlers still run; and the exit status of the process is unaffected. So a handler cannot be used to turn a successful run into a failed one — if the caller must learn that shutdown work failed, the code has to detect and report the failure before shutdown, or write a marker the next start can read. ### Where handlers sit relative to object finalization Handlers run early in interpreter finalization, before the bulk of module and object teardown. An object whose `__del__` would also print something will typically be finalized *after* the handlers, not before — so a handler that reaches for a resource "the object will close later" still sees it open, while an object finalizer that reaches for something a handler already tore down sees the wreckage. ### Practical shape of a good handler Keep it short, keep it non-blocking, and make it idempotent. A handler that waits on a work queue or joins on something still in flight turns a clean shutdown into a hang, and the operator's response to a hang is a signal that skips the handler completely. Anything long-running belongs in an explicit shutdown call the program makes on its own terms; `atexit` is the safety net for the paths that forgot to make it.

  • If the same function is registered three times, how many times does it run, and what does one unregister call do?
    It runs three times — each `atexit.register` call appends its own entry, complete with its own arguments. A single `atexit.unregister(func)` removes every entry whose callable is that function, so all three disappear at once. Unregistering a function that was never registered is a no-op rather than an error, which makes teardown code that is unsure of its own state easy to write.
  • At shutdown, does an object's __del__ run before or after the atexit handlers?
    Handlers run first. `atexit` callbacks are invoked early in interpreter finalization, and the bulk of object and module teardown — including `__del__` on objects still alive — happens afterwards. So a handler can still safely touch objects it references, but a finalizer must not assume a handler has not already closed the resource underneath it.
  • Why does a forked child process often call os._exit instead of returning normally?
    The child inherits the parent's registered handler list and its buffered output. Returning normally would run the parent's cleanups a second time — flushing the same buffers, deleting the same temp files, closing the same shared handles — from a process that never owned them. `os._exit` ends the child without unwinding, without flushing, and without running any inherited handler.

saying these in an interview costs you the question

  • Says handlers run in registration order
  • Believes atexit survives SIGKILL
  • Thinks os._exit still flushes buffers and runs handlers
  • Relies on a failing handler to set a nonzero exit status
  • Puts blocking, long-running work in a shutdown handler
  • Treats atexit as a durability guarantee for unsaved data

context