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 aftertp_finalizecompletes.
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:
- Python docs:
object.__del__— official warnings and semantics - CPython issue #53387: Allow objects to decide if they can be collected by GC
- PEP 442 — Safe object finalization
- Python-dev discussion: Adding a scarier warning to
object.__del__ - Stack Overflow: Garbage collector and problems with
__del__
Why It Matters
Each of these three failure modes has caused production incidents:
-
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__. -
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.
-
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
| Approach | Cycle-safe | Exceptions visible | Shutdown-safe | Deterministic | Best for |
|---|---|---|---|---|---|
__del__ | ✅ (PEP 442) | ❌ swallowed | ❌ crashes | ❌ | C extension subclasses only |
weakref.finalize | ✅ | ✅ | ✅ (no self) | ❌ | Non-deterministic cleanup |
| Context manager | ✅ | ✅ | ✅ | ✅ | Resource cleanup (files, locks) |
atexit.register | ✅ | ✅ | ✅ | ✅ | Process-level shutdown |
When to use what
- Files, sockets, locks → Context manager (
withstatement). Deterministic, safe, Pythonic. - Background resources (cache pools, connection pools) →
weakref.finalize. Runs on GC, doesn’t hold a reference to self. - Process shutdown →
atexit.register. Runs during normal interpreter shutdown, not during GC. - C extension subclasses →
__del__only if you’re extending a C type that requires thetp_finalizeslot.
Quick audit checklist
- Does your class define
__del__? - Does it access module-level names (
open,os,logging, database connections)? - Is it ever part of a reference cycle?
- 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.