skip to content

What happens to an exception raised inside `__del__` in Python?

level: middleimportance: should knowfreq 32%

answer

  1. There is no caller to raise into
  2. Ignored, not propagated
  3. It goes to standard error instead
  4. sys.unraisablehook is the hook
  5. Finalizer runs at most once

basics

~20 s

It never propagates. There is no meaningful call site to raise it into, so CPython reports it through sys.unraisablehook, which by default prints a message starting "Exception ignored in" to standard error, and execution continues as if nothing happened.

solid answer

~50 s

A finalizer is called by the interpreter during deallocation, at a point that has nothing to do with the code currently running, so there is no caller to receive an exception. CPython therefore treats it as an **unraisable** exception: it hands it to `sys.unraisablehook`, whose default implementation writes `Exception ignored in: <bound method ...__del__>` plus the traceback to `sys.stderr`, and then carries on. Nothing in your program can catch it with `try`/`except`. That is the core reason `__del__` is a bad cleanup hook: a failure inside it is invisible to your error handling, invisible to a caller's retry logic, and easy to lose entirely if stderr is discarded. If you must have a finalizer, wrap its body in your own `try`/`except` and log deliberately, or install a `sys.unraisablehook` that routes these into your logging pipeline.

code

python · 15 lines
python
import sys


class Boom:
    def __del__(self):
        raise RuntimeError("cleanup failed")


def handler(unraisable):
    print("unraisable:", type(unraisable.exc_value).__name__, unraisable.exc_value)


sys.unraisablehook = handler
Boom()                        # the temporary dies immediately
print("caller keeps running")

go deeper

for a junior

Remember the headline: an exception inside __del__ is not raised into your code. It gets printed and ignored, so a try/except in the surrounding function will never see it.

for a middle

Explain why: the finalizer is called by the interpreter during deallocation, with no caller to unwind into, so CPython routes it to sys.unraisablehook, which prints to stderr by default. Know that the same visit is granted only once per object.

for a senior

Show that you would not leave these silent. Route unraisable exceptions into structured logging, and argue that cleanup which can fail belongs on an explicit release path where the caller can retry or fail loudly.

for a principal

Frame it as an observability and ownership question: any cleanup whose failure your system cannot detect is cleanup your system does not really perform. Decide as a matter of policy where implicit finalization is permitted at all.

### Why the exception has nowhere to go Normal exception propagation walks the call stack: the raise unwinds frames until some frame has a matching handler. A finalizer has no such stack relationship with your code. CPython calls `__del__` from inside object deallocation, which is triggered by a reference count reaching zero -- and that can be *anywhere*: in the middle of an unrelated function, between two bytecodes, inside the cyclic collector, during interpreter shutdown. Propagating the exception into whatever frame happened to be executing would inject a failure into code that never touched the dying object. So the interpreter refuses to do it. ### The unraisable path Instead the exception becomes an **unraisable exception**. CPython passes a small record to `sys.unraisablehook`, carrying `exc_type`, `exc_value`, `exc_traceback`, an `err_msg` string and `object`, the object being finalized. The default hook prints a block to `sys.stderr` beginning `Exception ignored in:` followed by a traceback, and returns. The interpreter then continues exactly where it was. Your `try`/`except` around the code that dropped the last reference will not fire, because from that code's point of view nothing was raised. This hook is a real extension point. In a service that ships logs off the box, replacing `sys.unraisablehook` with a function that emits a structured error record is the difference between finding these failures and never seeing them at all -- the default output goes to stderr, which is very often the least-monitored stream in a containerized deployment. A hook that raises will itself be ignored, so keep it defensive. ### Resurrection The second surprise of finalization is **resurrection**: `__del__` receives `self`, so it can store a new strong reference to the object somewhere that outlives the finalizer -- append it to a module-level list, register it in a dict, hand it to a callback. When the finalizer returns, the object's refcount is no longer zero, and the object goes on living. CPython copes: since Python 3.4 (PEP 442) an object's `__del__` is guaranteed to be called **at most once**, and a flag on the object records that it has been finalized. So a resurrected object stays usable, but when it dies again its finalizer does not run a second time. That is the trap. Code that resurrects deliberately -- object pools that recycle instances are the usual excuse -- silently loses its cleanup on the second death. Code that resurrects accidentally, by handing `self` to a logging call that keeps a reference, has an object that never truly goes away. ### Why this makes `__del__` the wrong cleanup hook Put the two behaviours together. A cleanup step that fails inside a finalizer reports nowhere your code can see, and there is no way for a caller to know the cleanup did not happen, retry it, or fail the operation. That is a straightforward correctness hazard: the program keeps going with a resource it believes was released. Compare this with the explicit pattern -- an object with a `close()` method, driven by a `with` statement -- where a failure propagates to the caller normally and can be handled, retried or logged with full context. ### `weakref.finalize` as the better tool `weakref.finalize(obj, func, *args, **kwargs)` registers a callback to run when `obj` is garbage collected, and it is designed around these problems. The callback and its arguments are stored by the finalize object, not by the dying object, so a well-written callback never has a reference to `obj` at all and therefore cannot resurrect it -- capturing `obj` in the callback would keep the object alive forever and the finalizer would simply never run, a mistake worth checking for. The finalize object is callable, exposes `alive`, `peek()` and `detach()`, guarantees the callback runs at most once, and by default also runs it at interpreter exit via its `atexit` flag. Exceptions inside the callback are still unraisable -- there is no escaping that -- but you have a single, testable function to wrap in your own error handling instead of a method smeared across a class hierarchy. ### What to say in an interview State the rule first: exceptions in `__del__` are swallowed and reported through `sys.unraisablehook` rather than raised. Then give the consequence: no caller can react to a failed cleanup, so cleanup that matters must be explicit. Mention resurrection and the at-most-once guarantee as the second half of "finalizers are strange", and offer `weakref.finalize` as the tool you actually reach for.

  • What is object resurrection during finalization, and what does Python guarantee about it?
    `__del__` gets `self`, so it can store a new strong reference and the object survives past its own finalization. Since Python 3.4 (PEP 442) the interpreter flags the object as finalized, so `__del__` is called **at most once** in the object's lifetime. A resurrected object is fully usable but will never be finalized again -- any cleanup you relied on is silently skipped the second time.
  • How would you make finalizer failures visible in a long-running service?
    Install a `sys.unraisablehook` that emits a structured log record with the exception and the finalized object, instead of letting the default write to `sys.stderr` where it is easy to lose. Keep the hook defensive, since an exception raised inside it is itself ignored. Better still, move the cleanup out of `__del__` into an explicit release path where failures propagate normally.
  • Can a `weakref.finalize` callback resurrect its object the way `__del__` can?
    Not if it is written correctly. `weakref.finalize` stores the callback and its arguments separately from the object, and the callback is not given a reference to it. If you do capture the object in the callback -- for example by passing `obj` itself as an argument -- you keep it alive permanently and the callback never runs at all, which is a different bug with the same root cause.

saying these in an interview costs you the question

  • Thinks a try/except around `del obj` catches it
  • Expects the exception to crash the interpreter
  • Never mentions `sys.unraisablehook` or stderr
  • Believes `__del__` can run more than once per object
  • Puts failure-prone cleanup in a finalizer and calls it safe

context