Python weakref.finalize and atexit — the signal that must still fire
A destructor you cannot schedule is not a cleanup plan.
<!-- hal:authoritative:yaml -->
A destructor you cannot schedule is not a cleanup plan.
§I — Frame
Today's Ops lesson ends with a Lambda that must still PUT a JSON body to ResponseURL after apply_seat raises. The work can die. The signal cannot. That duty has a language-level form, and it is not __del__.
del x looks like destruction. It is not. del is a statement. It removes a name, or a slot, or a key. The object behind that name lives as long as any other reference remains. When the last reference goes, CPython decrements a counter. If the counter hits zero, CPython may call __del__. A cycle can keep the counter above zero forever. The cyclic collector may then delay the call, or skip it. Other implementations may not call __del__ promptly at all.
So __del__ is not a destructor you can schedule. Lutz said the quiet part: destructors are useful for cleanup (a server connection is the example) and uncommon, because you cannot always predict when an instance is reclaimed (Learning Python, "Object Destruction: __del__"). Ramalho names the mechanism (Fluent Python, 2nd ed., Ch.6, "del and Garbage Collection"). Neither book will let you treat __del__ as finally.
The callback that still runs when the owner dies, without holding the owner alive, is weakref.finalize(obj, func, *args). The process-lifetime cousin is atexit.register. They are different clocks. Coin the same name the Ops slot coined: the signal that must still fire.
This is not 08-11. That lesson was __aenter__ / __aexit__ under cancellation, a scope you still own. This lesson is the moment you no longer own the object. It is not 08-14 match/case, not 08-08 descriptors, not 08-02 Protocols.
§II — Foundations: four facts about the last reference
**Fact one. del deletes a reference, not an object.**
Ramalho is blunt. del is a statement, not a function. del x unbinds the name x in the current scope. del s[i] deletes a slot. del d[k] deletes a key. The object is untouched by the statement itself. If other names still point at it, nothing is reclaimed. If a container still holds it, nothing is reclaimed. If a traceback, a finally cell, or a closure cell still holds it, nothing is reclaimed.
The operator habit is to write del session and believe the STS client is closed. The name is gone. The object may still sit in a cache, a list of leases, or a cycle. del is the unbind. Reclamation is a later fact, if it happens.
**Fact two. __del__ runs, if it runs, when CPython's refcount hits zero.**
On CPython, an object whose refcount reaches zero is reclaimed immediately, and __del__ is called as part of that reclaim. That sentence has three traps.
A cycle keeps every refcount above zero. Two objects that point at each other, or an object that points at a callback that points back, never hit zero. The cyclic garbage collector is a second, later pass. It may collect the cycle. It may delay. If any object in the cycle defines __del__, the collector may skip the cycle rather than guess a safe order. Ramalho records that delay-or-skip. It is why __del__ plus a back-reference is a cleanup that does not run.
Other implementations (PyPy, Jython historically, anything not refcounting) may not call __del__ promptly. A library that closes a socket in __del__ and is tested only on CPython has a hidden clock. The socket stays open until that runtime feels like collecting.
__del__ during interpreter shutdown is worse. Names in module globals are set to None in an unspecified order. A __del__ that does self.socket.close() may find self.socket already None, or socket the module already None. The method that was "cleanup" becomes an AttributeError at process death, swallowed or not depending on the version. Unsafe is the right word.
Fact three. Lutz's warning is the ops warning.
Lutz places __del__ under object destruction and then takes it back: useful for cleanup of things like server connections, uncommon because you cannot always predict when the instance is reclaimed. A connection you must close is a duty with a clock. __del__ does not give you one. An ops client that closes a temp file, a socket, or an STS session in __del__ is hoping. Hope is not a signal.
**Fact four. Two clocks that still fire: finalize per object, atexit per process.**
weakref.finalize(obj, func, *args) registers func(*args) to run when obj is collected. The finalizer holds a weak reference to obj. It does not keep obj alive. It does not put func on obj as an attribute that points back. That is the cycle __del__ keeps walking into: self._cb = lambda: self.close() stores a closure over self, which is a reference, which is a cycle if anything else is.
finalize returns a Finalize object. You can call it yourself to run the callback early. You can .detach() to cancel. You can ask .alive. The callback receives the arguments you passed at register time, not the dying object. That is the point. If the callback needed the object, it would keep it alive, or it would run against a half-dead instance the way __del__ does.
atexit.register(func, *args) runs func when the interpreter is leaving normally. It is process-lifetime. It does not fire when a single lease is collected at minute two. It does not fire on os._exit, SIGKILL, or a fatal abort. It is too late for a file-descriptor budget and too global for a seat you meant to close per owner.
Interpreter shutdown, condensed: atexit runs; then module globals become None; then remaining __del__ methods may run against that wreckage. finalize is built to run when the object dies, including during shutdown, without looking up globals on the dying instance. Pass the path, the fileno, the STS client as arguments at register time. Do not ask the object.
§III — Worked example: a lease that closes without __del__
The owner is a small ops client. It assumes a role, writes temporary credentials to a scratch file so a child command can read them (the 08-14 hop, seen from this side), and holds a boto3 session. When the owner is collected, the session must close, the scratch file must unlink, and the socket must not wait for process exit. __del__ is the first draft. It is the wrong draft.
class StsLease:
def __init__(self, session, scratch):
self.session = session
self.scratch = scratch
def __del__(self):
self.session.close()
self.scratch.unlink(missing_ok=True)
Two references to self sit in that method the moment you add logging, a callback, or a registration that stores the instance. A cycle with the session's own cache is enough. On shutdown, self.session may already be None. Lutz's unpredictability is no longer abstract.
The finalize form registers the work against the values, not against self.
import weakref
from pathlib import Path
def release_lease(scratch: Path, session, closer):
try:
closer(session)
finally:
scratch.unlink(missing_ok=True)
class StsLease:
def __init__(self, session, scratch: Path, closer):
self.session = session
self.scratch = scratch
scratch.write_text(session.token_blob)
self._finalizer = weakref.finalize(
self,
release_lease,
scratch,
session,
closer,
)
def close(self):
self._finalizer()
def closed(self) -> bool:
return not self._finalizer.alive
release_lease is a module-level function. It does not close over self. The finalizer holds scratch, session, and closer as its own arguments. When StsLease is collected, those arguments are still reachable from the finalizer, which is the intent: the signal has what it needs, the owner does not have to be alive.
close() calls the finalizer early. That is the explicit path, the one a with statement would take if you also implement the context-manager protocol. 08-11 already taught __aenter__ / __aexit__. This lesson does not redo that. The point here is the path you get when nobody called close: the owner went out of scope, a cache dropped the last strong reference, and the signal still fires.
closed() is the test. After close(), .alive is false. A second close() is a no-op. That is the language-level cousin of the Ops idempotent delete: the thing is already gone, the signal is still allowed to say yes, and it must not raise.
A caller that keeps the lease in a dict keyed by account, then del leases[account], unbinds the dict slot. If that was the last strong reference, the finalizer runs. If a log extra still holds the lease, nothing runs yet. del is still only the unbind. The signal waits for the last strong reference, which is correct, and which is why you do not also register __del__ "as backup." Two cleanup paths on one object is how a scratch file is unlinked twice and a session is closed against a second closer that no longer exists.
§IV — Failure mode: the signal that does not fire
**__del__ plus a cycle.** The lease stores self._on_done = lambda: self.close(). The lambda is a cell pointing at self. self points at the lambda. Refcount never hits zero. Cyclic GC sees __del__ and may skip the cycle. The scratch file stays. The STS session stays. The operator sees del lease in the code and believes the seat is closed. Nothing fired.
**__del__ at shutdown.** The process is leaving. atexit has already run. Module globals are None. self.session.close() becomes None.close(). The exception is ignored or printed on stderr, depending on the interpreter. The scratch file remains in /tmp. The next run reuses a stale token blob if you key by a stable name. Unsafe is not a style note. It is a leftover credential.
**atexit as the only closer.** You register flush_all_leases at import. Every lease born during the process waits for process exit. A long-running inventory that assumes a role per account now holds twelve sessions until cron sends SIGTERM. 08-14 taught what SIGTERM does to a child. atexit does not run on SIGKILL and does not run on os._exit. Even on a clean SystemExit, the clock is wrong: you needed the file descriptor back at account three, not at process death. atexit is the global signal. It is the right tool for "flush the process-wide audit log." It is the wrong tool for "this one STS session must close when its owner dies."
**finalize that closes over self.**
weakref.finalize(self, lambda: self.session.close())
The lambda holds self. The finalizer's weak reference is now competing with a strong one in the callback. The object never dies. The signal never fires. Pass self.session as an argument. Write a module-level function. If you need more than three arguments, pass a small tuple of values, not the owner.
**finalize on a function you will rebind.** weakref.finalize(self, self.close) looks tidy. self.close is a bound method. A bound method holds the instance. Same cycle, dressed as elegance. Pass the function and the values separately.
The tell is a process that grows file descriptors or temp files while del lines execute in the log. The names are gone. The objects are not. len(gc.garbage) and weakref.finalize .alive are the instruments. __del__ print statements are not.
§V — Pairing
Today's Ops lesson is a stack-lifetime finalize. CloudFormation holds a wait. The Lambda is the owner of the work. The finally: post_signal(...) is the callback that still runs when apply_seat dies. If you skip the finally and raise, the stack stays CREATE_IN_PROGRESS for about an hour. That is __del__ skipped by a cycle, written in AWS. The URL is the Finalize object. The JSON body is release_lease.
Today's Cert lesson is DOP-C02 Domain 2. The exam stem is the same duty: no response from a custom resource fails the operation. CreationPolicy and WaitCondition are two other clocks the stack can wait on. A stack policy is a signal you cannot take back once applied. AWS Config is the witness that still fires outside the stack when someone changes a resource by hand. The language clock, the Lambda clock, and the exam clock are one coin: the signal that must still fire.
08-11 remains the scope you own. __aexit__ runs because the async with is still on your stack. finalize runs because the object is no longer on anyone's stack. Do not put both on one lease without a single closer.
§VI — Drills
del lease runs. lease was the only name in local scope. A module-level cache still holds the instance. Does __del__ run? Does a weakref.finalize registered on that instance run?del unbound the local name. The cache is a remaining strong reference. Reclamation has not happened.StsLease.__del__ calls self.session.close(). At interpreter shutdown the line raises AttributeError: 'NoneType' object has no attribute 'close'. What already ran, and what should have been registered instead?weakref.finalize(self, closer, session) with the session passed as an argument, or close explicitly before exit.weakref.finalize(self, self.close). The object never dies. Why, and what is the fix?scratch, session, closer), not self.close.Related
- Prior arc: the subject, not the keys (2026-08-14)
- Domain hub: Cross-References/domains/Polyglot-Dev
- Grounding tome: Fluent Python, 2nd ed. (Ch.6 del and Garbage Collection) · Learning Python (Lutz) (Object Destruction: __del__)
🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-17 · Fajr · sprint track Python day 26 · ninth Python visit · trio #92