The comforting model says an object dies when it goes out of scope, so its destructor can release resources. Every clause is unreliable. Scope and reachability differ. Python does not promise prompt collection. Objects participate in cycles. Finalizers can resurrect objects. Interpreter shutdown dismantles infrastructure. Processes can terminate without running Python cleanup at all.
Memory reclamation may be eventual; resource release often cannot be. Files, transactions, locks, temporary directories, and network connections need an explicit lexical or lifecycle boundary. Finalization is a fallback, not the boundary itself.
Python guarantee.
__del__may run when an object is about to be finalized, but execution is not guaranteed at interpreter exit, timing is unspecified, exceptions are ignored and reported, and execution can occur in precarious contexts. Context-manager semantics are deterministic relative to control flow.
Version note. Experiments ran on standard GIL-enabled CPython 3.14.7. Prompt acyclic finalization is a CPython reference-counting behavior, not portable Python. PEP 442 made cyclic finalization safer in Python 3.4; shutdown details, GC policy, thread context, and C-level finalization are version/implementation specific.
Experiment 1: leaving a function is not the core rule
import gc
events = []
class Resource:
def __del__(self):
events.append("finalized")
def create():
resource = Resource()
return resource
kept = create()
gc.collect()
print(events)
del kept
gc.collect()
print(events)
Returning transfers a reference out of the local scope, so the object remains reachable. On tested CPython it finalizes after deletion. Another implementation may defer it. The portable fact is only that a live reference prevents ordinary reclamation.
Avoid tests that assert immediate __del__ calls without an explicit collection and implementation scope. Better, test an explicit close contract and separately test fallback best-effort behavior.
Context managers encode the reliable boundary
The with statement calls __enter__, executes the body, then calls __exit__ even when the body raises. __exit__ receives exception information and may suppress the exception by returning a truthy value. Most resource managers should return false and let failures propagate.
Experiment 2: cleanup runs on exceptional control flow
class Managed:
def __enter__(self):
print("[event] resource opened")
return self
def __exit__(self, exc_type, exc_value, traceback):
print("[event] resource closed after:", exc_type.__name__ if exc_type else "clean exit")
return False
try:
with Managed():
raise ValueError("failed")
except ValueError:
print("[error] body exception propagated: ValueError")
This ordering is language behavior, independent of garbage collection. Put acquisition and release in one abstraction. If acquisition has multiple stages, release only stages actually acquired, often with contextlib.ExitStack.
Make close idempotent
Explicit close, context-manager exit, error rollback, and a fallback finalizer may converge. One guarded cleanup implementation prevents double release. Decide what methods do after close and make thread synchronization explicit.
Experiment 3: one close path serves normal and repeated calls
class Connection:
def __init__(self):
self.closed = False
self.releases = 0
def close(self):
if self.closed:
return
self.closed = True
self.releases += 1
def __enter__(self):
return self
def __exit__(self, *exc_info):
self.close()
connection = Connection()
with connection:
pass
connection.close()
print("[check] closed state and release count:", connection.closed, connection.releases)
Real release code should often mark or detach state before invoking callbacks that could re-enter. Idempotence does not automatically make concurrent close safe; protect shared transitions when multiple threads or tasks can close.
__del__ runs under hostile conditions
A finalizer may run from an arbitrary thread that drops the last reference. It can run while another exception is active. During interpreter shutdown, globals may be missing. Blocking, acquiring locks, importing modules, or relying on event loops can deadlock or fail.
Exceptions cannot be raised back to the code that caused finalization. CPython reports them through sys.unraisablehook. Finalizers should not implement business logic whose failure must be observed.
Experiment 4: finalizer exceptions are unraisable
import gc
import sys
events = []
original = sys.unraisablehook
class Broken:
def __del__(self):
raise RuntimeError("cannot report normally")
sys.unraisablehook = lambda report: events.append(type(report.exc_value).__name__)
value = Broken()
del value
gc.collect()
sys.unraisablehook = original
print(events)
The tested build records RuntimeError. Customizing the process-wide hook is useful in tests and observability but needs careful restoration and thread awareness. The exact moment remains implementation-dependent.
Cycles complicate timing, not just reachability
Modern Python can collect many cycles containing __del__ safely because of PEP 442. The collector identifies an isolated cycle, invokes finalizers, checks whether resurrection made it reachable, and then breaks references. This replaced old advice that every finalizer cycle necessarily becomes permanent garbage.
Experiment 5: a finalizer cycle can be collected
import gc
import weakref
events = []
class Node:
def __del__(self):
events.append("done")
first = Node()
second = Node()
first.other = second
second.other = first
reference = weakref.ref(first)
del first, second
gc.collect()
print(reference() is None)
print(len(events))
On CPython 3.14 both finalizers run and the weak reference is dead. Code must not depend on their relative order or expect one object's attributes to represent a pristine world while a cycle is being torn down.
Resurrection breaks one-shot intuitions
__del__ can store self somewhere reachable. The object survives, potentially in a partially cleaned state. Python calls a given object's __del__ at most once in CPython's PEP 442 lifecycle even if that object later dies again, but portability and clarity demand avoiding resurrection.
Experiment 6: a finalizer brings its object back
import gc
saved = []
class Phoenix:
def __del__(self):
saved.append(self)
value = Phoenix()
identifier = id(value)
del value
gc.collect()
print(len(saved), id(saved[0]) == identifier)
saved.clear()
gc.collect()
print(len(saved))
The object returns through saved. This demonstrates why finalization is a state transition, not an ordinary method call at a predictable source location. Never intentionally resurrect resource owners; separate durable data from resource handles instead.
weakref.finalize is a safer fallback
Finalizers decouple callback state from the object and avoid defining __del__, but arguments must not retain the target. A bound method like weakref.finalize(self, self.close) strongly references self and can prevent triggering.
Experiment 7: fallback cleanup uses detached state
import gc
import weakref
released = []
class Handle:
def __init__(self, token):
self.token = token
self._finalizer = weakref.finalize(self, released.append, token)
def close(self):
self._finalizer()
first = Handle("explicit")
first.close()
first.close()
second = Handle("fallback")
del second
gc.collect()
print(released)
Calling a finalizer explicitly runs it at most once, providing a compact idempotence mechanism. Still expose and prefer close or with; abrupt process death bypasses fallback.
Async cleanup adds cancellation
async with awaits __aenter__ and __aexit__. Cleanup can itself be cancelled because it has suspension points. Libraries must define whether cancellation is delayed, shielded, retried, or allowed to interrupt release. Shielding everything can make shutdown hang; shielding nothing can leak scarce resources.
Experiment 8: async exit observes body failure
import asyncio
class AsyncManaged:
async def __aenter__(self):
print("[event] async resource acquired")
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await asyncio.sleep(0)
print("[event] async resource released after:", exc_type.__name__ if exc_type else "clean exit")
return False
async def main():
try:
async with AsyncManaged():
raise LookupError("body")
except LookupError:
print("[error] async body exception propagated: LookupError")
asyncio.run(main())
The protocol guarantees the exit call as control leaves the body, but application cancellation policies around awaited cleanup require deliberate design and tests. Do not attempt async work in __del__; there may be no running loop, and finalizers cannot await.
Shutdown is not normal execution
atexit handlers run on normal interpreter termination in reverse registration order, but not after os._exit, fatal signals, or process termination. Starting threads or forking from handlers is restricted in current Python. Module globals may be in teardown, and handlers are process-global rather than scoped to one object.
Use atexit for best-effort process-level housekeeping, not correctness-critical commits. Durable systems should commit before acknowledging work, use transactional storage, and recover from abandoned leases after crashes. Operating-system primitives often release file descriptors and locks on process exit, but application protocols may require explicit rollback or expiry.
Generators also have cleanup behavior: closing injects GeneratorExit, and finally blocks can run. Abandoned generators and async generators still inherit timing and loop-shutdown complications. Wrap resource-producing generators with documented context management instead of assuming exhaustion.
Engineering guidance
Rank cleanup mechanisms by reliability: explicit operation at a known lifecycle boundary; synchronous or asynchronous context manager; owner-managed stack; detached weak finalizer; __del__ only when unavoidable. Every fallback should be idempotent and independent of fragile globals.
Design acquisition so partial failure can unwind. ExitStack and AsyncExitStack register cleanup immediately after each successful step, then transfer ownership only after complete setup. Avoid constructors that acquire many external resources because a partially initialized object has a difficult finalization story.
Test success, body exception, acquisition failure, repeated close, cancellation, and process crash recovery. Leak tests should verify external resource counts or explicit owner state, not merely force gc.collect and assume success. Log unraisable exceptions in controlled environments.
Do not rely on CPython's prompt reference counting as API behavior. A service may migrate to another implementation, a cycle may appear after refactoring, or a debugger may hold a traceback. Correct cleanup should happen at the same source-level boundary regardless.
Ownership across system boundaries
Some cleanup cannot be completed by the process that acquired a resource. A worker may die while holding a distributed lease, processing a message, or writing a multipart upload. Those protocols need server-side expiry, fencing tokens, transactional acknowledgement, or a separate recovery job. Adding __del__ only improves the graceful local path and can hide the missing crash model.
Define who closes returned resources. A function returning an open stream transfers a cleanup obligation; its documentation and type should make that visible, and callers should immediately enter a context. An iterator that internally owns a stream is harder: early loop exit may leave it open. Expose a context-managed iterator or materialize data inside the owner rather than relying on generator finalization.
Pools invert ordinary ownership. Returning a connection to a pool is not the same as closing its socket, and a leaked checkout can starve the application before garbage collection notices. Model checkout as a context manager whose exit restores pool invariants. A fallback may log or discard the connection, but it should not silently make normal misuse acceptable.
Shutdown ordering deserves explicit orchestration. Stop accepting work, cancel or drain producers, await tasks, flush application buffers, close clients, and only then tear down loops and logging. Object finalizers cannot infer this dependency graph. Process-level owners can use ExitStack, task groups, or a service container to release components in reverse acquisition order.
Observability must survive cleanup failure. Record close errors at the explicit boundary where they can be associated with an operation. Metrics emitted only from __del__ may run after telemetry has closed and become unraisable noise. Decide whether a close error replaces a body exception, is attached as context, or is logged while preserving the primary failure; context-manager code should implement that policy deliberately.
The strongest test is a subprocess killed at inconvenient points. Verify that temporary files, leases, transactions, and messages recover according to external protocol guarantees. Unit tests of __exit__ are necessary but cover only cooperative control flow. Reliable systems assume some finalizers never run and make the next process capable of repairing or safely abandoning their work.
Exercises
- Convert a resource class with
__del__into an idempotent context manager plus detached finalizer. - Use
ExitStackto acquire three resources and fail during the second; verify the first is released. - Write a resurrection example and list every invariant that can become ambiguous.
- Test an async context manager under cancellation during its body and during
__aexit__. - Classify your application's cleanup duties as object-level, request-level, process-level, or crash-recovery work.
Keep this model
Reachability controls eligibility for finalization, not a reliable cleanup deadline. Finalizers run under weak guarantees, cycles alter ordering, resurrection can reverse death, and shutdown can remove the services cleanup expects. CPython's prompt common case is convenient but not a contract.
Make resource lifetime explicit and lexical where possible. Centralize an idempotent release transition, use context managers to invoke it, and let a detached finalizer provide only best-effort insurance. Design durable correctness for abrupt termination rather than hoping every object receives a farewell call.