skip to content

Why choose weakref.finalize over a __del__ method for cleanup in a long-running job?

level: seniorimportance: should knowfreq 30%

answer

  1. Prefer the registration over the method
  2. Which one can hand the object back
  3. Per-instance beats per-class for cleanup
  4. Neither guarantees when it runs
  5. Best-effort backstop, not the primary path

basics

~20 s

weakref.finalize is registered per object rather than defined on the class, cannot receive or resurrect the object, fires at most once, and gives you an explicit handle to cancel or trigger it. It also works on classes you do not own, and its exit pass is ordered and reported.

solid answer

~50 s

A `__del__` method is part of the class: it is inherited, overridable, easy to forget to call up the MRO, and it receives `self`, so it can resurrect a dying object and it can reach anything the object references. `weakref.finalize` inverts all of that. It is registered per instance, so you can attach it to a class you do not own; the callback never sees the referent, so resurrection is impossible; it is guaranteed at-most-once; and you get a handle to cancel it with `detach()`, run it early by calling it, or read `.alive`. At shutdown, live finalizers run in reverse creation order and an exception in one is reported via `sys.excepthook` without stopping the rest. Neither mechanism is a scheduling guarantee, though — for a long batch job, keep `with` blocks or explicit `close()` calls as the primary path and treat the finalizer as the backstop that turns a leak into late cleanup.

code

python · 15 lines
python
import atexit
import weakref


class Job:
    pass


atexit.register(print, "atexit callback registered first")
a = Job()
weakref.finalize(a, print, "finalizer for a (created first)")
b = Job()
fb = weakref.finalize(b, print, "finalizer for b (created second)")
fb.atexit = False
print("main body done")

go deeper

for a junior

Know that both mechanisms clean up automatically, that neither promises a time, and that a with block or an explicit close() is what you should reach for first in ordinary code.

for a middle

Contrast them concretely: registered per instance versus defined on the class, no access to the object versus receiving self, an at-most-once guarantee and a cancel handle versus none.

for a senior

Demonstrate the diagnosis: finalizers firing only at exit means something still holds the objects, and you can name the usual holders and how you would confirm one. Pair the backstop with a startup sweep for resources a hard kill leaves behind.

for a principal

Set the policy: which resources may be released non-deterministically at all, what the service must reclaim out-of-band after an unclean exit, and how retention limits keep a long job from turning an automatic cleanup into an end-of-run stampede.

## The concrete failure that makes people ask A nightly flight-schedule differ runs for about six hours, streaming yesterday's and today's schedules and writing a diff. Each comparison unit opens a scratch file, and each one registers a cleanup so the scratch file goes away when the unit is done with. In staging, where the job processes a few hundred units, everything is tidy. In production, descriptor usage climbs all night and the scratch directory holds tens of thousands of files by morning — and all of them disappear the instant the process exits, so every post-mortem shows a clean disk. The cause was not the finalization mechanism at all: a helper written as `def load(path, cache={})` used a mutable default argument, which is created once at function-definition time and shared by every call, so the cache accumulated parsed schedules — and therefore the units that owned them — for the entire six-hour run. Nothing was collectible, so nothing was finalized until the exit pass. That is the first thing to say about non-deterministic cleanup: **it releases when the object dies, and you often do not control when the object dies.** ## Why finalize is the better of the two automatic mechanisms Given that you want an automatic cleanup at all, `weakref.finalize` beats writing `__del__` on nearly every axis that matters in production. **It is registration, not inheritance.** `__del__` is a class attribute: subclasses inherit it, an override that forgets to call up the MRO silently disables it, and two base classes with their own cleanup collide. A finalizer is attached to one instance at construction time, so composition works. **It cannot resurrect.** `__del__` receives `self` and can store it somewhere, re-animating an object the interpreter had already decided was dead. A finalizer callback never receives the referent, so that whole class of bug is unreachable. **It works on objects you did not write.** You can register a finalizer against a third-party object, or against something whose class you must not modify. There is no equivalent for `__del__` short of subclassing or monkey-patching. **It fires at most once, and you can see it.** `.alive` tells you whether cleanup is still pending; calling the finalizer runs it now; `detach()` cancels it. `__del__` gives you no handle at all. **Its errors are routed, not swallowed.** During the shutdown pass an exception in one callback is passed to `sys.excepthook` and the remaining finalizers still run. ## The shutdown pass, precisely Every finalizer still alive when the interpreter exits is called then, newest first — reverse creation order, on the assumption that later objects may depend on earlier ones. The `weakref` module registers a single hook with the `atexit` machinery the first time any finalizer is created, which produces one behaviour worth knowing: because `atexit` callbacks run last-registered-first, anything you register with `atexit.register` *after* your first finalizer runs *before* all the finalizers, and anything registered before them runs after. Set `.atexit = False` on a finalizer whose cleanup is meaningless during shutdown. ```python import atexit, weakref class Job: pass atexit.register(print, "atexit callback registered first") a = Job(); weakref.finalize(a, print, "finalizer for a") b = Job(); f = weakref.finalize(b, print, "finalizer for b") f.atexit = False ``` ## What neither mechanism buys you Both are best-effort. Neither runs after SIGKILL, `os._exit`, a segfault, or a container being reaped, and neither promises *when* it runs. So the production stance for a six-hour batch job is: 1. **Scope what you can.** `with` blocks and `contextlib.ExitStack` for anything whose lifetime matches a block. That is deterministic and visible in review. 2. **Bound what you cannot.** Cap the number of live units, pool and reuse handles, and do not let a cache silently retain everything the job has touched. 3. **Register a finalizer as the backstop.** It converts "a resource leaked" into "a resource was released later than intended", which is a much better failure. 4. **Clean up out-of-band.** A hard kill leaves scratch files behind no matter what, so the job that creates them needs a sweep on startup, or a directory the platform reclaims. The senior answer to "finalize or `__del__`?" is therefore: prefer `finalize` between the two, and prefer neither as the primary path.

  • In what order do still-live finalizers run at interpreter exit, and what happens if one raises?
    They run in reverse order of creation — newest first — because later objects are more likely to depend on earlier ones. If a callback raises, the exception is passed to `sys.excepthook`, printed, and the pass continues with the next finalizer; it does not abort the others or change the exit status. That is why exit-time callbacks should be small and should not depend on module globals that may already be torn down.
  • You are diagnosing a six-hour job whose finalizers all fire at exit instead of during the run. Where do you look?
    For whatever is keeping the objects reachable, because a finalizer that never fires early means the referent never died. Common holders: a mutable default argument used as a cache, a module-level list or dict that only ever grows, a logger or callback registry holding instances, and closures captured by long-lived objects. Confirm with `gc.collect()` plus a `weakref.ref` that should have gone dead, then walk the referrers with the `gc` module's introspection.
  • Would you ever still write __del__ instead?
    Rarely, and mostly for a class that must clean up regardless of how it was constructed — including subclass paths that never run your `__init__`, or C-level state where the teardown genuinely needs the instance. Even then the modern idiom is to register the finalizer in `__init__` and document that subclasses must call it. If you do write `__del__`, keep it minimal and remember it can be entered during interpreter shutdown.

A del method is a clause in the tenancy agreement that every subclass can rewrite; a finalizer is a standing instruction lodged with the building, attached to one flat, that nobody inside can edit or use to move back in.

saying these in an interview costs you the question

  • Calls either mechanism a deterministic destructor
  • Claims finalizers survive SIGKILL or os._exit
  • Thinks a finalizer callback can resurrect its object
  • Uses finalization as the primary release path
  • Assumes exit-time callbacks run oldest first
  • Misses that an unreachable-looking object is still cached

context