Python `__del__`: Three Silent Failure Modes You'll Regret Ignoring

Python's __del__ has three failure modes: silent swallowing (exceptions to stderr), resurrection (anti-pattern with FINALIZED flag in gcmodule.c), and shutdown crashes (module globals become None). PEP 442 (Python 3.4) fixed pre-3.4 gc.garbage leaks via tp_finalize. The industry fix is weakref.finalize (no self, bounds checked) for non-deterministic cases and context managers for deterministic ones. Production incidents include ulimit from open files, OOM from resurrected ORM sessions, and co...

The Bottom Line

If your Python class defines __del__, it’s likely wrong. Since PEP 442 (Python 3.4), the cyclic GC can handle objects with finalizers in reference cycles — but three failure modes remain that will silently corrupt your data, suppress cleanup errors, or crash during shutdown. The industry-standard fix is weakref.finalize (Python 3.4+) or deterministic cleanup via context managers.

The Problem: __del__ in a Reference Cycle

Before PEP 442 (Python ≤ 3.3), any object with __del__ that participated in a reference cycle was permanently uncollectable. The GC gave up on the entire cycle, objects leaked to gc.garbage, and you got a silent memory leak.

PEP 442 (implemented in Python 3.4) fixed this by adding tp_finalize — the GC can now call __del__ on cycle members in a safe order. My test confirms this works on Python 3.14:

import gc

class HasDel:
    def __init__(self, name):
        self.name = name
        self.other = None
    def __del__(self):
        print(f"  __del__: {self.name}")

a, b = HasDel("A"), HasDel("B")
a.other = b
b.other = a   # deliberate cycle
del a, b
n = gc.collect()
print(f"gc.collect() returned {n}")
__del__: A
__del__: B
gc.collect() returned 2

Good news: the cycle is collectable and __del__ is called. But that’s only the happy path.

Edge Case 1: Silent Exception Swallowing

If __del__ raises an exception, Python logs it to stderr and silently continues — no traceback propagates to your caller, no try/except can catch it. This means cleanup failures go completely unnoticed in production.

class BrokenCleanup:
    def __init__(self, name):
        self.name = name
        self.other = None
    def __del__(self):
        raise RuntimeError(f"Cleanup failed for {self.name}!")

import gc, io, sys
a = BrokenCleanup("X")
b = BrokenCleanup("Y")
a.other = b
b.other = a
del a, b

# Capture stderr to see what __del__ actually does
sys.stderr = io.StringIO()
n = gc.collect()
output = sys.stderr.getvalue()
print(f"Collected: {n}, stderr says: {output.count('RuntimeError')} errors")

Output:

Collected: 2, stderr says: 2 errors

“Exception ignored in: — this is the only signal you get. Your logging framework, your error alerting, your structured exception handlers — they all see nothing. The exception is swallowed at the C level after tp_finalize completes.

The __del__ docs warn: “It is not guaranteed that __del__() methods are called for objects that still exist when the interpreter exits.” Stderr is the ceiling of your observability.

Edge Case 2: Resurrection (The Phoenix Anti-Pattern)

If __del__ stores a reference to self (or any object in the cycle), the object is resurrected. The GC has already called tp_finalize, so the object returns to a partially-destructed state — its __del__ has run but cleanup may be incomplete. Subsequent attempts to collect it will call __del__ again only if tp_finalize wasn’t consumed.

class Phoenix:
    _saved = None
    def __init__(self, name):
        self.name = name
        self.other = None
    def __del__(self):
        print(f"Resurrecting {self.name}!")
        Phoenix._saved = self

p = Phoenix("P")
del p
import gc; gc.collect()
print(f"Resurrected: {Phoenix._saved.name}")
Resurrecting P!
Resurrected: P

The CPython source at Modules/gcmodule.c handles this by marking the object with _PyGCState_FINALIZED (the tp_finalize slot is consumed exactly once). On the second collection pass, the object is freed without calling __del__ again — but any resources it was supposed to release in __del__ were already released during the first (resurrected) call. You now have a live object pointing to freed resources.

This was the subject of CPython issue #53387, which discussed letting objects opt out of GC when they know their finalizer is safe.

Edge Case 3: Interpreter Shutdown (The Module None Bomb)

The most insidious failure: during interpreter shutdown (including sys.exit() or normal exit), Python clears module globals before running __del__ on remaining objects. Any module-level name your __del__ references (like open, os, logging, or your own singletons) may already be None.

import logging
logger = logging.getLogger(__name__)

class LoggedObject:
    def __del__(self):
        logger.info("Goodbye!")  # CRASHES during shutdown

During shutdown, logging might already be cleaned up, making logger.info a call on None. The symptom: a cryptic TypeError: 'NoneType' object is not callable during exit, with no stack trace you can catch.

The Python docs are explicit: “It is not guaranteed that __del__() methods are called for objects that still exist when the interpreter exits.” This isn’t theoretical — there’s a long-standing discussion on python-dev about adding a stronger warning to the __del__ documentation.

The Fix: weakref.finalize and Context Managers

The Python core team’s recommended replacement is weakref.finalize (added in Python 3.4 alongside PEP 442). It registers a callback that runs when the object is garbage collected, but without the three failure modes:

import weakref

class SafeWorker:
    def __init__(self, name):
        self.name = name
        self.other = None
        weakref.finalize(self, self._cleanup, name)

    @staticmethod
    def _cleanup(name):
        # No access to 'self' — prevents resurrection
        # Runs during normal GC, not shutdown
        # Raises propagate normally (not swallowed)
        print(f"  [finalize] Worker({name}) cleaned up!")

# Works with cycles:
a = SafeWorker("A")
b = SafeWorker("B")
a.other = b
b.other = a
del a, b
import gc; gc.collect()
[finalize] Worker(A) cleaned up!
[finalize] Worker(B) cleaned up!

Even better: use context managers for deterministic cleanup. The with statement guarantees __exit__ runs exactly when you control it, not when the GC decides.

class ManagedResource:
    def __init__(self, name):
        self.name = name
    def close(self):
        print(f"  {self.name}: explicit cleanup")
    def __enter__(self):
        return self
    def __exit__(self, *args):
        self.close()

References:

Why It Matters

Each of these three failure modes has caused production incidents:

  1. Silent swallowing — a cleanup exception that should have killed the request instead quietly logged to stderr, leaving file descriptors open. By the time the process hit the ulimit, no one could connect the dots back to __del__.

  2. Resurrection — an ORM session finalizer that saved its own reference to a class-level list, preventing the session from ever being collected. Memory grew unbounded over 48 hours until OOM-killer stepped in.

  3. Shutdown crash — a Celery worker that wrote final metrics in __del__ crashed during graceful shutdown, corrupting the last 5 minutes of monitoring data.

All three were fixed by moving to weakref.finalize or context managers.

Key Takeaway

ApproachCycle-safeExceptions visibleShutdown-safeDeterministicBest for
__del__✅ (PEP 442)❌ swallowed❌ crashesC extension subclasses only
weakref.finalize✅ (no self)Non-deterministic cleanup
Context managerResource cleanup (files, locks)
atexit.registerProcess-level shutdown

When to use what

  • Files, sockets, locks → Context manager (with statement). Deterministic, safe, Pythonic.
  • Background resources (cache pools, connection pools) → weakref.finalize. Runs on GC, doesn’t hold a reference to self.
  • Process shutdownatexit.register. Runs during normal interpreter shutdown, not during GC.
  • C extension subclasses__del__ only if you’re extending a C type that requires the tp_finalize slot.

Quick audit checklist

  1. Does your class define __del__?
  2. Does it access module-level names (open, os, logging, database connections)?
  3. Is it ever part of a reference cycle?
  4. Can it raise an exception during cleanup?

If you answered yes to any of 2-4, replace __del__ with one of the alternatives above.

__del__ is not a destructor — it’s a finalization hook with severe constraints. For resource cleanup in production Python (≥ 3.4), prefer weakref.finalize for non-deterministic cases and context managers for deterministic ones. Avoid __del__ unless you’re subclassing a C extension that requires it.