skip to content

What happens to an exception raised inside a `__del__` finalizer in CPython?

level: middleimportance: should knowfreq 45%

answer

  1. It cannot reach the caller
  2. Printed, then discarded
  3. A replaceable hook on sys
  4. Exception ignored in ... on stderr
  5. Remainder of the finalizer is skipped

basics

~20 s

It never reaches the surrounding code. CPython hands it to sys.unraisablehook, whose default prints an "Exception ignored in" traceback to stderr and returns. The rest of the finalizer is skipped, and the program carries on as if nothing failed.

solid answer

~40 s

A finalizer runs at a moment nobody asked for it — inside whatever statement happened to drop the last reference — so propagating its exception would surface a failure in unrelated code. CPython therefore catches it and routes it to `sys.unraisablehook`, replaceable since Python 3.8; the default implementation writes `Exception ignored in: <function ...>` plus the traceback to `sys.stderr` and returns normally. Two consequences matter in production. First, the remainder of `__del__` after the raise never executes, so whatever it was releasing stays unreleased. Second, nothing in the program's control flow observes the failure: no `except` clause fires, no test fails, no exit code changes. To make such errors visible you install your own hook that records or reports them, and you keep real cleanup in a `with` block where exceptions can propagate normally.

code

python · 12 lines
python
import sys

class Importer:
    def __del__(self):
        raise ValueError("batch boundary off by one")

def hook(unraisable):
    print("unraisable:", unraisable.exc_type.__name__, unraisable.exc_value)

sys.unraisablehook = hook
Importer()
print("caller keeps running")

go deeper

for a junior

Recall the headline: an error inside __del__ never reaches your code. Python prints an Exception ignored in: traceback and keeps going, so you cannot wrap the finalizer in a try/except from outside.

for a middle

Explain the mechanics — the exception is caught at the C level and handed to sys.unraisablehook, replaceable since Python 3.8, whose default prints to stderr — and state that everything after the raise in the finalizer is skipped.

for a senior

Demonstrate the operational view: unraisable errors leave cleanup half-done and never fail a build, so wire a recording hook into test and production startup and treat those reports as real incidents rather than log noise.

for a principal

Own the standard: decide that release paths are deterministic and observable across services, that ignored finalizer errors are surfaced by monitoring rather than buried in stderr, and that finalizers stay diagnostic-only so no failure can hide there.

### Why the exception cannot propagate A finalizer is not called by anyone. It runs on the deallocation path, at whatever point in the program the object's last reference happened to disappear — the middle of a loop, the return of an unrelated function, a garbage-collection pass triggered by someone else's allocation. If CPython let a `__del__` exception propagate, the traceback would blame code that has nothing to do with the failure, and an `except` clause written for a completely different purpose could swallow it. So the interpreter treats it as *unraisable*: it is caught at the C level, reported, and discarded. ### The reporting path Since Python 3.8 the report goes through `sys.unraisablehook`, a module-level function you may replace. It receives a single argument carrying the exception type, the exception instance, the traceback, an error message, and the object being finalized. The default implementation prints ```text Exception ignored in: <function Importer.__del__ at 0x...> Traceback (most recent call last): ... ValueError: batch boundary off by one ``` to `sys.stderr` and returns. Note what does *not* happen: the process exit status is unchanged, no `except` block anywhere runs, and the statement that dropped the last reference completes as though the finalizer had succeeded. The same machinery reports other exceptions the interpreter cannot raise anywhere sensible, such as a failure inside a weak-reference callback. ### The half-finished-cleanup trap The second consequence is quieter and worse. A finalizer is usually a sequence of releases: ```python class Importer: def __del__(self): self._flush_pending() # raises IndexError on a boundary case self._handle.close() # never reached ``` When the first step raises, the rest of the method is abandoned. The object is still torn down and its memory reclaimed, but the file handle it owned is never closed by that code path. Under load this shows up as descriptor exhaustion whose stack trace points nowhere near the finalizer. ### A worked failure A museum-catalogue importer batches records and flushes the tail of each batch from a finalizer. An off-by-one on the batch boundary makes the last slice index one element past the end, so the finalizer raises `IndexError` — but only for catalogues whose record count lands exactly on the boundary. The 27-minute test suite stays green: the exception cannot fail a test, because no test is executing when it fires, and the assertion phase never sees it. All that appears is an `Exception ignored in:` block interleaved into captured output, easily mistaken for noise. The bug is found in production, as truncated catalogues. The fix has two halves. The importer's flush moves into an explicit `close()` called from a `with` block, where an `IndexError` propagates and fails a test on the spot. And the suite installs a hook that turns unraisable exceptions into a visible failure: ```python import sys ignored = [] sys.unraisablehook = ignored.append # ... run the workload ... assert not ignored, [u.exc_value for u in ignored] ``` Recording rather than raising is deliberate: an exception raised *inside* the hook is itself unraisable, so raising there just produces a second ignored traceback. Collect, then assert from ordinary code. ### Shutdown makes the report worse, not better Finalizers that run during interpreter shutdown execute in a half-torn-down world. Module globals may already be gone, so a `__del__` referring to a module-level name can raise `NameError` or `AttributeError` that would never occur at any other time, and the printed traceback can be truncated because the machinery needed to format it is itself being dismantled. A finalizer should therefore be short, defensive, and dependent on as few globals as possible — one more reason not to put load-bearing logic there. ### What to say in the room "It becomes an unraisable exception: CPython prints `Exception ignored in:` through `sys.unraisablehook` and continues. Nothing catches it, the rest of the finalizer is skipped, so cleanup silently half-completes. In a test suite I install a hook that records them and assert the list is empty; in production code I keep the real release in a context manager."

  • Why should a custom `sys.unraisablehook` record the failure instead of re-raising it?
    Because an exception raised inside the hook is itself unraisable: CPython catches it and reports it through the default handler, so you gain a second ignored traceback and no control-flow change. Append the argument to a list, log it, or increment a counter, then assert or alert from ordinary code that can actually fail.
  • How does a resource leak arise from an exception in the middle of `__del__`?
    The finalizer aborts at the raise, so every release after that line is skipped while the object itself is still destroyed. A handle, socket or lock the object owned is now unreferenced and unclosed, and the eventual symptom — descriptor exhaustion or a stuck lock — appears far from the finalizer that caused it.
  • Why do finalizers raise `NameError` at interpreter shutdown that never occur while the program runs?
    Shutdown tears the interpreter down around still-live objects, so module globals a finalizer references can already be gone by the time it runs. Keeping `__del__` short and having it use only attributes of `self`, with a defensive `try`/`except`, avoids failures that only ever appear as the process exits.

saying these in an interview costs you the question

  • Says the exception propagates to whoever triggered the collection
  • Expects a surrounding try/except to catch a finalizer's error
  • Thinks the process exit code reflects an ignored finalizer failure
  • Assumes the rest of `__del__` still runs after the raise
  • Believes the message is harmless noise with no cleanup consequence
  • Re-raises inside a custom unraisable hook and expects it to escape

context