skip to content

Why can Python's __del__ method run late, never run, or silently hide an exception?

level: middleimportance: must knowfreq 55%

answer

  1. Finalizer, not a destructor
  2. The moment is the runtime's choice
  3. Something is still holding the object
  4. No caller left to raise into
  5. Reported to stderr, then discarded

basics

~20 s

del fires when CPython actually destroys the object, not when your code stops using it, and the language promises nothing for objects still alive when the process ends. Any exception it raises has no caller to receive it, so it is reported and discarded.

solid answer

~50 s

`__del__` is a finalizer, not a destructor you call. CPython invokes it when the object is genuinely being freed, which can be much later than the last use — anything still holding the object, including a traceback captured by an exception, keeps it alive — and the documentation explicitly does not guarantee that finalizers run for objects still alive at interpreter exit. A hard ending such as `os._exit` or an uncaught signal skips them all. Because the interpreter calls `__del__` from the deallocation path, there is no caller to propagate an error to: the exception is handed to `sys.unraisablehook`, which prints an *ignored exception* traceback to stderr and lets execution continue, so failures disappear from normal error handling and from anything that only watches exit status. Real resource release therefore belongs in an explicit close path, with `weakref.finalize` as the safety net.

code

python · 10 lines
python
class Handle:
    def __del__(self):
        raise RuntimeError("cleanup failed")

def use():
    h = Handle()
    print("leaving use()")

use()
print("still running")

go deeper

for a junior

Recall that del obj removes a name, not necessarily the object, and that __del__ runs whenever the interpreter finally frees it. Do not rely on it to close a file — use an explicit close path.

for a middle

Explain the mechanics: what keeps an object alive past its last use, why a finalizer's exception cannot propagate and lands on sys.unraisablehook instead, and why nothing is promised for objects alive at interpreter exit.

for a senior

Demonstrate the production instinct: audit where finalizer-based cleanup can silently not run, route unraisable errors into real logging, and move the actual release onto an explicit, idempotent, observable path.

for a principal

Frame it as a contract question: who owns a resource's lifetime across a codebase, whether the ownership convention is explicit enough to review, and what the system is allowed to lose when cleanup is skipped.

### Finalizer, not destructor The single most useful sentence about `__del__` is that it is a *finalizer*, not a destructor. In a language with scope-bound destruction, an object dies at a syntactically visible point. In Python, an object dies when the runtime decides nothing can reach it any more, and `__del__` is a hook the runtime calls on the way to freeing it. Nothing in the language ties that moment to a line of your code. That produces three separate failure modes, and a good answer separates them. ### 1. It runs later than you think Any surviving handle on the object postpones finalization. Some are obvious — the object is in a list, a cache, a closure, a module global. Some are not: * an exception's traceback holds the frames it passed through, and those frames hold their locals, so a caught-and-stored exception can pin an object indefinitely; * the interactive interpreter keeps the last expression result in `_`; * a debugger, a profiler, or a saved stack frame does the same. The practical symptom is a resource that is "closed by `__del__`" and yet stays open across the whole run, which shows up as exhausted file handles or sockets long before it shows up as memory. ### 2. It may never run at all The documentation makes no promise that `__del__` is called for objects that are still alive when the interpreter exits. Modern CPython in fact finalizes most surviving objects during shutdown, which is exactly what makes this dangerous: it works locally and stops working when the process ends abruptly. Every hard ending skips finalization outright — `os._exit`, a default SIGTERM or SIGKILL, a fatal error in a C extension. And even in the graceful case, ordering during shutdown is not something to build on: the module a finalizer wants to call into may already be tearing down. ```python import os class Res: def __del__(self): print("cleanup ran") r = Res() os._exit(0) # nothing is printed, and nothing is flushed ``` ### 3. Its exceptions vanish `__del__` is called from the deallocation machinery, in the middle of whatever the program happened to be doing. There is no meaningful caller to receive an exception and no sensible place in the source to attribute it to, so CPython refuses to propagate it. Instead the exception is passed to `sys.unraisablehook`, whose default implementation prints a traceback labelled as ignored and returns. Execution continues; no `except` clause anywhere can catch it; the exit status is unchanged. That is a real operational hazard. Cleanup logic that fails inside a finalizer produces stderr noise that no exception-based alerting notices — and if stderr is discarded, produces nothing at all. Overriding `sys.unraisablehook` to route these through the logging pipeline is a cheap, high-value hardening step for a long-running service. ### Two more sharp edges **Resurrection.** A finalizer can store `self` somewhere reachable and bring the object back to life. CPython tolerates it — since the finalization rules were tightened, `__del__` is called at most once per object, so a resurrected object will not be finalized again. **Cost and surprise.** Defining `__del__` changes an object's teardown path for every instance, and inheritance means a subclass that overrides `__del__` without calling up silently loses the parent's cleanup. ### How to see the delay for yourself The cheapest demonstration is to give a class a finalizer that prints, create an instance inside a function, and then compare two runs: one where the function returns normally, and one where the instance is also appended to a module-level list. In the first the message appears as the function returns; in the second it does not appear until shutdown, if at all. Doing the same with an exception — catching one raised while the instance is on the stack and storing it in a variable — shows the same delay for a much less obvious reason, because the stored exception's traceback still holds the frame that holds the instance. ### What to do instead Make release explicit. The object should expose a `close()` (or equivalent) that is idempotent and that the owner calls on a definite path. Then, if you want a net for the case where nobody called it, register `weakref.finalize` rather than writing `__del__`: it runs at most once, it does not sit on the object's own teardown path, it can be cancelled or invoked early, and it also fires at normal interpreter exit for objects that are still alive. `__del__` is still worth writing for one purpose: a warning. Detecting at finalization time that a resource was never closed, and reporting it, turns a silent leak into a visible defect — while the real closing still happens on the explicit path.

  • How would you make a failure inside a finalizer visible to your monitoring instead of only to stderr?
    Install your own `sys.unraisablehook`. It receives an object carrying the exception type, value, traceback and the object being finalized, so the replacement can log through the service's normal logging pipeline, increment a counter, or sample the message. Keep the hook defensive — it runs at arbitrary points, so it must not raise, must not block, and must tolerate a partly torn-down interpreter during shutdown.
  • Is there any legitimate use left for __del__ once explicit close methods exist?
    Yes: as a leak detector. Having the finalizer emit a warning when it finds the resource still open turns a silent handle leak into a visible signal during tests and staging, while the actual release stays on the explicit path the owner calls. Writing the release itself in `__del__` is what fails, because the timing is not yours and the errors do not propagate.
  • What does it mean for a finalizer to resurrect its object, and can it then run twice?
    Resurrection is a finalizer storing `self` — or letting something else capture it — so the object becomes reachable again and is not freed. CPython allows this, but it marks the object as already finalized, so `__del__` will not be called a second time when the object eventually dies. Code that relies on cleanup happening on that second death gets nothing.

A finalizer is the cleaning crew that comes when the building is finally demolished, not when you walk out of the office. If the site is bulldozed without notice, they never arrive at all.

saying these in an interview costs you the question

  • Calls __del__ a destructor that runs at end of scope
  • Wraps a finalizer body in try/except expecting propagation
  • Assumes finalizers always run at interpreter exit
  • Puts the only resource release inside __del__
  • Thinks del obj always triggers __del__ immediately
  • Expects a failing finalizer to fail the process

context