Python Weak References — Stop the 2GB/hour Leak
Event bus bound methods in a list caused 2GB/hour memory growth.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- A weak reference points to an object without incrementing its reference count. The object can still be garbage-collected.
- weakref.ref(obj) creates a weak reference. Call ref() to access the object; returns None if the object is dead.
- WeakValueDictionary automatically removes entries when their values are collected — perfect for caches.
- Performance: weak reference access adds roughly 20-50ns overhead per call versus a direct attribute access on CPython 3.12. Measure before optimising.
- Production failure: observer pattern without weak references keeps listeners alive forever — memory grows until OOM.
- Biggest mistake: storing bound methods in a WeakSet expecting them to persist. Bound methods are temporary objects — use weakref.WeakMethod instead.
Weak references let you reference an object without preventing its garbage collection. In CPython, reference counting normally keeps objects alive — every strong reference increments the count, and the object is only freed when the count hits zero. A weak reference doesn't increment that count, so the object can be collected even while the weak reference exists.
This solves the classic memory leak caused by reference cycles: two objects referencing each other (e.g., a cache holding values that hold references back to the cache) can never be freed by reference counting alone. Weak references break those cycles by letting one side hold a non-owning pointer.
CPython implements this via the weakref module, which wraps a PyWeakReference object that tracks the referenced object's identity and resurrection status. When the object is collected, the weak reference's callback fires and the reference becomes None — you check it with or wr().callable.
The most practical tool is WeakValueDictionary, which automatically removes entries when their values are garbage collected — perfect for caches that should never pin objects in memory. Without it, a naive dict cache holding references to large objects (like loaded images or database rows) can leak gigabytes per hour under load.
The observer pattern is another common sink: event listeners that hold strong references to subscribers prevent their cleanup, causing unbounded growth. Weak references let you register listeners without owning their lifecycle. For cleanup logic, weakref.finalize is safer than __del__ because it runs deterministically when the object is collected, not during interpreter shutdown, and it avoids the resurrection pitfalls of __del__.
Use weak references when you need to observe or cache objects without controlling their lifetime — never use them for objects you need to keep alive, and never assume the weak reference still points to a live object without checking.
Imagine you put a sticky note on a library book saying 'I want to read this next.' That note does not stop the librarian from returning the book to another branch — it just tells you where the book was. A weak reference works the same way: it points to an object in memory, but it does not stop Python's garbage collector from deleting that object when nobody else needs it. The moment the object disappears, your weak reference simply returns None. A regular reference, by contrast, is like physically holding the book — the librarian cannot take it until you put it down.
Memory leaks in long-running Python services are sneaky. Your application chews through RAM over hours, your monitoring fires at 3 a.m., and the culprit is almost always the same thing: an object that should have died is being kept alive by a reference nobody bothered to clean up. Caches, event listeners, observer patterns, and circular data structures are repeat offenders.
Python's reference-counting garbage collector is simple in concept: an object lives as long as its reference count is above zero. The problem is that certain architectural patterns accidentally keep reference counts permanently elevated. A cache that maps IDs to live objects. An event bus where listeners hold back-references to subjects. A graph with parent-child cycles. None of these patterns announce themselves as leaks. They just slowly consume memory until the process dies or someone notices at 3 a.m.
Weak references solve this by letting you point at an object without incrementing its reference count. The object can still be collected normally, and the weak reference quietly becomes None the moment it is.
But weak references come with their own sharp edges. Storing a bound method in a WeakSet and expecting it to survive. Assuming WeakValueDictionary entries clear immediately. Using weakref.proxy in production without catching ReferenceError. These are not theoretical concerns — they show up in code review and production incidents.
By the end of this article you will understand how CPython's weakref machinery works under the hood, when to reach for weakref.ref, WeakValueDictionary, WeakKeyDictionary, WeakSet, and WeakMethod, how to write finalizer callbacks that are actually safe, and the production mistakes that separate engineers who have shipped this from engineers who only read the docs.
Why Weak References Exist — and Why Your Memory Leak Is a Reference Cycle
A weak reference is a reference to an object that does not increase its reference count, and does not prevent the object from being garbage collected. In CPython, objects are deallocated when their reference count hits zero. A weak reference lets you observe an object without owning it — if all strong references disappear, the weak reference silently returns None. This is the core mechanic: weak references break reference cycles, which are the #1 cause of memory leaks in Python applications that use callbacks, caches, or observer patterns. Without weak references, a cache that stores objects will keep them alive forever, even if the rest of the application has no use for them. Weak references are implemented via the weakref module, and the most common pattern is WeakValueDictionary, which maps keys to objects but does not prevent those objects from being garbage collected. The key property: weak references are not hashable by default, and they are not iterable — you must check if the reference is still alive before using it. Use weak references when you need to associate metadata with an object without extending its lifetime, or when building caches that should not pin objects into memory. In production, this is the difference between a service that runs for weeks and one that OOMs after 2 hours.
How Weak References Work — The CPython Implementation
A regular Python reference increments an object's ob_refcnt field. When that count drops to zero, CPython deallocates the object immediately. Weak references are a completely separate mechanism: they register a pointer to the object but do not touch ob_refcnt.
At the C level, CPython supports weak references through two mechanisms. First, a per-type slot called tp_weaklistoffset in the PyTypeObject struct indicates where the weakref list pointer lives within instances of that type. Custom classes automatically get this slot — that is why you can weakly reference your own classes but not built-in types like int, str, or tuple. Those built-in types do not include tp_weaklistoffset in their type definition. This has nothing to do with interning or immortality — it is simply that their C struct does not have a slot for the weakref list pointer. Large integers and non-interned strings have the same limitation for the same reason.
Second, when you call weakref.ref(obj), CPython allocates a PyWeakReference structure and appends it to the object's weakref list. The PyWeakReference stores a raw pointer to the object and an optional callback function. The object's reference count is not touched.
When the object's reference count reaches zero and CPython begins deallocation, it walks the weakref list and sets the wr_object pointer in each PyWeakReference to NULL. Any callback functions are called at this point with the now-dead weak reference as the argument. Then the object is freed.
Calling a dead weak reference — ref() — returns None because the internal wr_object pointer is NULL.
This design is pay-as-you-go. Objects without any weak references have zero overhead — no extra memory, no extra pointer, nothing. The weakref list is only allocated when the first weak reference to an object is created.
The weakref module exposes this C machinery as: weakref.ref(obj, callback) for a single weak reference, proxy(obj) which raises ReferenceError on dead access, WeakValueDictionary and WeakKeyDictionary for containers, WeakSet for sets of weakly-referenced objects, WeakMethod for bound methods, and finalize for finalizer callbacks.
One operational detail: on CPython, the GIL protects weak reference list manipulation. On Python 3.13+ with the free-threaded build (no-GIL), weakref operations are internally protected by a per-object lock. If you are running experimental no-GIL builds, be aware that concurrent weakref manipulation has changed semantics.
import weakref import gc class ExpensiveObject: """Simulates a resource-heavy object we want collected when not needed.""" def __init__(self, name: str) -> None: self.name = name print(f"Creating {self.name}") def __del__(self) -> None: print(f"Deleting {self.name}") def demo_basic_weakref() -> None: print("\n=== Basic weakref ===") obj = ExpensiveObject("obj1") weak_obj = weakref.ref(obj) print(f"ref() while alive: {weak_obj()}") # Returns the object print(f"ref is not None: {weak_obj() is not None}") obj = None # Remove strong reference gc.collect() print(f"ref() after collection: {weak_obj()}") # None def demo_callback() -> None: print("\n=== Callback on collection ===") obj = ExpensiveObject("obj2") def on_delete(weak_ref: weakref.ref) -> None: # The weak_ref argument here is the dead weakref, not the object. # Do not try to call weak_ref() here — it returns None. print(f"Callback fired: object is gone") weak_obj = weakref.ref(obj, on_delete) obj = None gc.collect() def demo_builtin_types() -> None: print("\n=== Built-in types do not support weakref ===") # Built-in types lack tp_weaklistoffset in their C type struct. # This is not about caching or immortality — it is a C struct design choice. for obj in [42, "hello", (1, 2), [1, 2]]: try: ref = weakref.ref(obj) print(f"weakref.ref({type(obj).__name__}) succeeded") except TypeError as e: print(f"weakref.ref({type(obj).__name__}): {e}") # Custom classes work — tp_weaklistoffset is included automatically class MyClass: pass obj = MyClass() ref = weakref.ref(obj) print(f"Custom class weakref: {ref() is not None}") def demo_weak_value_dict() -> None: print("\n=== WeakValueDictionary ===") cache: weakref.WeakValueDictionary = weakref.WeakValueDictionary() obj = ExpensiveObject("cached_obj") cache["key"] = obj print(f"Cache size before del: {len(cache)}") obj = None gc.collect() print(f"Cache size after del: {len(cache)}") # 0 print(f"cache.get('key'): {cache.get('key')}") # None def demo_weak_set() -> None: print("\n=== WeakSet ===") listeners: weakref.WeakSet = weakref.WeakSet() obj1 = ExpensiveObject("listener1") obj2 = ExpensiveObject("listener2") listeners.add(obj1) listeners.add(obj2) print(f"Listeners before: {len(listeners)}") obj1 = None gc.collect() print(f"Listeners after obj1 del: {len(listeners)}") # WeakSet iteration yields only live objects — no None checks needed inside the loop. # This is different from iterating a list of weakref.ref objects manually. for listener in listeners: print(f"Live listener: {listener.name}") def demo_weak_method() -> None: print("\n=== WeakMethod for bound methods ===") class Handler: def handle(self, data: str) -> None: print(f"Handling: {data}") handler = Handler() # WRONG: weakref.ref on a bound method — dies immediately bad_ref = weakref.ref(handler.handle) print(f"weakref.ref(handler.handle): {bad_ref()}") # None — already dead # CORRECT: weakref.WeakMethod — survives as long as handler is alive good_ref = weakref.WeakMethod(handler.handle) print(f"WeakMethod alive: {good_ref() is not None}") good_ref()() # Call the bound method via the weak reference del handler gc.collect() print(f"WeakMethod after del: {good_ref()}") # None if __name__ == "__main__": demo_basic_weakref() demo_callback() demo_builtin_types() demo_weak_value_dict() demo_weak_set() demo_weak_method()
- Strong reference = holding the book. The librarian cannot move it while you hold it.
- Weak reference = a sticky note. The book can be moved; your note just becomes invalid.
- weakref.ref(obj.method) = putting a note on a photocopy. The photocopy (bound method) has no permanent home — it is gone before you finish writing the note. Use WeakMethod instead.
- WeakSet = a notice board with sticky notes. When a book leaves, its note is removed automatically. You never see empty slots during iteration.
- If the book still exists, the note tells you where it is. If it is gone,
ref()returns None.
weakref.ref() and check for None explicitly before use.ref() to access the object — returns None if dead. Check for None before every use.WeakValueDictionary — The Auto-Cleaning Cache You Need
A regular dictionary keeps its values alive indefinitely. That is correct for bounded caches with explicit eviction policies. But for caches that map identifiers to objects with unpredictable lifetimes — active database sessions, live request contexts, in-flight user objects — a regular dict is a slow memory leak masquerading as a cache.
WeakValueDictionary solves this precisely: when a value loses all its strong references outside the dictionary, the dictionary entry is automatically removed. The key remains a strong reference. The value is weak. When the value dies, the key and the entry vanish together.
The canonical use case is an object identity cache — you want at most one User(id=5) object in memory at a time. If code A and code B both ask for user 5, they should get the same Python object, not two independent copies. WeakValueDictionary makes this trivially safe: when neither A nor B needs user 5 anymore, the cache clears the entry automatically. The next request reloads from the database. That is fine — the point of the cache is identity deduplication during active use, not persistent storage.
Two traps that catch experienced engineers.
First: WeakValueDictionary only helps when the dictionary is the last thing holding the object. If an ORM identity map, a background task, a global registry, or a logging handler also holds a reference, the dictionary entry stays alive. The dictionary is not your leak in that case — the other holder is. Use gc.get_referrers(obj) to find the real culprit.
Second: mutating a WeakValueDictionary during iteration raises RuntimeError — entries can be removed mid-iteration by the garbage collector. Always snapshot with list(d.items()) before iterating if you plan to inspect or modify during the loop.
A subtlety worth knowing: if the same object is stored under multiple keys, it has only one weak reference count relative to the dictionary. The object lives until its external strong reference count reaches zero, regardless of how many dictionary keys point to it. When it dies, all keys pointing to it are removed simultaneously.
import weakref import gc from typing import Optional class User: """Simulates an expensive database model with identity-cache semantics.""" _cache: weakref.WeakValueDictionary = weakref.WeakValueDictionary() def __init__(self, user_id: int, name: str) -> None: self.user_id = user_id self.name = name print(f"User {self.user_id} ({self.name}) loaded from DB") def __del__(self) -> None: print(f"User {self.user_id} ({self.name}) freed") @classmethod def get(cls, user_id: int, name: str = "Unknown") -> "User": """Return cached instance if alive, otherwise load from DB. Identity guarantee: two calls with the same user_id return the same object as long as at least one caller holds a strong reference. """ cached = cls._cache.get(user_id) if cached is not None: print(f"Cache hit for user {user_id}") return cached user = cls(user_id, name) cls._cache[user_id] = user return user def demo_cache_identity() -> None: print("=== Identity cache with WeakValueDictionary ===") u1 = User.get(1, "Alice") u2 = User.get(1, "Bob") # Cache hit — "Bob" ignored, returns Alice print(f"u1 is u2: {u1 is u2}") # True — same object print(f"Cache size: {len(User._cache)}") # Drop both strong references u1 = None u2 = None gc.collect() print(f"Cache size after drop: {len(User._cache)}") # 0 # Next call reloads from DB — cache miss u3 = User.get(1, "Charlie") print(f"Reloaded: {u3.name}") u3 = None gc.collect() def demo_safe_iteration() -> None: print("\n=== Safe iteration over WeakValueDictionary ===") d: weakref.WeakValueDictionary = weakref.WeakValueDictionary() users = [User(i, f"User{i}") for i in range(5)] for u in users: d[u.user_id] = u # Drop two of them users[2] = None users[4] = None gc.collect() # WRONG: iterating d.items() directly can raise RuntimeError # if a GC run removes entries mid-iteration. # RIGHT: snapshot first. for key, value in list(d.items()): print(f" user_id={key}, name={value.name}") print(f"Final cache size: {len(d)}") # Cleanup for u in users: u = None gc.collect() if __name__ == "__main__": demo_cache_identity() demo_safe_iteration()
d.items() directly without snapshotting first.d.items() to list(d.items()) in the audit loop.d.items()) before iterating — GC can remove entries mid-loop.gc.get_referrers().Observer Pattern Without Weak References — The Perpetual Memory Leak
The observer pattern is the single most common source of memory leaks in long-running Python services. Event buses, signal handlers, pub-sub systems, UI callbacks, ORM lifecycle hooks — they all share the same structure and the same failure mode.
Here is the mechanism. A subject maintains a collection of listener callbacks. When an event occurs, it iterates the collection and calls each callback. The callbacks are typically bound methods — listener_obj.handle_event. A bound method holds a strong reference to its instance through __self__. So the reference chain is:
subject._listeners → bound method → __self__ → listener instance
When the listener goes out of scope in application code, its reference count does not reach zero because the subject still holds a strong reference through the bound method. The listener never dies. Everything it references — request context, database cursors, user data, accumulated state — never dies either. Memory grows until the process is killed.
The bound method problem is subtle and catches experienced engineers. Storing listener.handle_event in a WeakSet does not fix this. A bound method is a temporary object created fresh on each attribute access. It has no persistent strong reference outside of the WeakSet entry itself. The WeakSet holds a weak reference to it — but with no strong reference anywhere, the bound method is collected immediately. The WeakSet entry dies before you leave the register() call.
The correct tools for this problem:
For storing listener objects — use WeakSet. Store the listener object itself, not the bound method. In emit(), iterate the WeakSet and call the method on each live listener.
For storing bound methods as callbacks — use weakref.WeakMethod. It holds the bound method weak reference alive as long as the underlying object is alive. When the object dies, WeakMethod() returns None.
WeakSet iteration automatically skips collected objects. You do not need None checks inside the iteration loop for WeakSet. The None check is only needed when you hold explicit weakref.ref objects and call them manually.
import weakref import gc from typing import Any, Callable # ============================================================ # VERSION 1: LEAKY OBSERVER — do not use in production # ============================================================ class LeakyEventBus: """Stores strong references to callbacks. Every registered listener lives forever regardless of scope. """ def __init__(self) -> None: self._callbacks: list[Callable] = [] def register(self, callback: Callable) -> None: self._callbacks.append(callback) def emit(self, data: Any) -> None: for cb in self._callbacks: cb(data) # ============================================================ # VERSION 2: WEAKSET OBSERVER — stores listener objects weakly # ============================================================ class WeakSetEventBus: """Stores weak references to listener objects via WeakSet. The listener object must be stored somewhere with a strong reference for the registration to remain active. When the listener object is collected, WeakSet automatically removes it — no cleanup needed. Use this when you control the listener class and can define the method name to call on emit. """ def __init__(self, method_name: str = "handle_event") -> None: self._listeners: weakref.WeakSet = weakref.WeakSet() self._method_name = method_name def register(self, listener: Any) -> None: self._listeners.add(listener) def emit(self, data: Any) -> None: # Snapshot to avoid mutation during iteration if emit triggers # new registrations or deletions. # WeakSet yields only live objects — no None check needed here. for listener in list(self._listeners): method = getattr(listener, self._method_name, None) if method is not None: method(data) # ============================================================ # VERSION 3: WEAKMETHOD OBSERVER — stores bound methods weakly # ============================================================ class WeakMethodEventBus: """Stores weak references to bound methods via weakref.WeakMethod. Allows registering any callable bound method without requiring the caller to store the listener object separately. The registration stays alive as long as the underlying object is alive. WeakMethod is the correct tool when you want to store callbacks (not objects) and let the callback die with its owner. """ def __init__(self) -> None: self._callbacks: list[weakref.WeakMethod] = [] def register(self, callback: Callable) -> None: self._callbacks.append(weakref.WeakMethod(callback)) def emit(self, data: Any) -> None: live_callbacks = [] for weak_cb in self._callbacks: cb = weak_cb() # Returns None if the owner was collected if cb is not None: live_callbacks.append(weak_cb) cb(data) self._callbacks = live_callbacks # Prune dead references # ============================================================ # Demonstration # ============================================================ class EventListener: def __init__(self, name: str) -> None: self.name = name def handle_event(self, data: Any) -> None: print(f"{self.name} received: {data}") def __del__(self) -> None: print(f"{self.name} was collected") def demo_leak() -> None: print("=== Leaky bus (listener survives scope) ===") bus = LeakyEventBus() def create_and_register() -> None: listener = EventListener("leaky_listener") bus.register(listener.handle_event) # listener local var drops here, but bus holds it via bound method.__self__ create_and_register() gc.collect() bus.emit("ping") # leaky_listener is still alive and receives this print(f"Bus callback count: {len(bus._callbacks)}") # 1 — never cleaned def demo_weakset_bus() -> None: print("\n=== WeakSet bus (listener collected when out of scope) ===") bus = WeakSetEventBus(method_name="handle_event") def create_and_register() -> None: listener = EventListener("weakset_listener") bus.register(listener) # listener drops here — WeakSet holds it weakly create_and_register() gc.collect() # listener collected, WeakSet entry removed bus.emit("ping") # no output — listener is gone print(f"Bus listener count: {len(bus._listeners)}") # 0 def demo_weakmethod_bus() -> None: print("\n=== WeakMethod bus (callback weak, tied to object lifetime) ===") bus = WeakMethodEventBus() long_lived = EventListener("long_lived") bus.register(long_lived.handle_event) bus.emit("first") # long_lived receives it long_lived = None gc.collect() # long_lived collected, WeakMethod becomes None bus.emit("second") # no output — callback pruned print(f"Bus callback count after collection: {len(bus._callbacks)}") # 0 if __name__ == "__main__": demo_leak() demo_weakset_bus() demo_weakmethod_bus()
weakref.finalize — Safer Cleanup Than __del__
The __del__ method has a reputation problem, and most of it is earned. It is not that __del__ does not work — it does, most of the time. The problem is the exceptions.
In Python 3.4 and later (PEP 442), __del__ is called for objects in reference cycles after the cyclic garbage collector runs. That part works. But __del__ still has three problems that make it unreliable in production.
First, timing is non-deterministic. You know __del__ will eventually run, but you do not know when. In CPython with no cycles, it runs at reference-count-zero, which is often immediate. In PyPy, Jython, or any implementation without reference counting, it runs whenever the GC decides. If your production service runs on multiple Python implementations or you plan to migrate runtimes, __del__ timing guarantees break.
Second, resurrection risk. If __del__ stores self somewhere — assigns it to a global, appends it to a list, passes it to a logger — the object's reference count rises above zero again. It has been resurrected. CPython handles this by marking the object as uncollectable in some cases. Your cleanup code ran. The object is now in an undefined state. This is a subtle bug that typically surfaces only under load.
Third, debugging difficulty. When __del__ raises an exception, CPython prints it to stderr and discards it. The exception does not propagate. You get a cryptic message in logs and no traceback context. Production log aggregation usually drops these.
weakref.finalize avoids all three problems. It attaches a callback to an object using a weak reference internally. When the object is collected, the callback fires with whatever arguments you explicitly provided at registration time. Crucially: the callback does not receive the object itself automatically — it receives exactly the arguments you passed to finalize(). This prevents you from accidentally capturing self in the callback closure and creating a resurrection cycle.
The detach() method lets you cancel the finalizer if you handle cleanup manually (for example, when using a context manager). alive property lets you check whether the finalizer has already fired.
One important constraint: finalizer callbacks should not be long-running. They execute during garbage collection, and a slow callback delays collection of everything waiting behind it.
import weakref import gc from typing import Optional class ConnectionPool: """Simulates a database connection pool.""" _available: list[int] = list(range(10)) @classmethod def acquire(cls) -> Optional[int]: return cls._available.pop() if cls._available else None @classmethod def release(cls, conn_id: int) -> None: cls._available.append(conn_id) print(f"Connection {conn_id} returned to pool") class DatabaseConnection: """Wraps a connection and ensures it is returned to the pool on collection. Uses weakref.finalize instead of __del__ for the following reasons: - finalize callback does not receive self, preventing resurrection. - finalize works correctly with reference cycles. - finalize can be detached early when context manager handles cleanup. - finalize does not suppress exceptions; callback exceptions propagate normally. """ def __init__(self) -> None: self.conn_id = ConnectionPool.acquire() if self.conn_id is None: raise RuntimeError("Connection pool exhausted") print(f"Connection {self.conn_id} acquired") # CORRECT: pass the conn_id as an argument, not self. # The callback receives conn_id directly — no reference to self, # no resurrection risk, no cycle. self._finalizer = weakref.finalize( self, ConnectionPool.release, self.conn_id ) def execute(self, query: str) -> None: if not self._finalizer.alive: raise RuntimeError("Connection already closed") print(f"[conn {self.conn_id}] {query}") def close(self) -> None: """Explicit close — detaches finalizer to prevent double-release.""" self._finalizer.detach() ConnectionPool.release(self.conn_id) def __enter__(self) -> "DatabaseConnection": return self def __exit__(self, *args) -> None: self.close() def demo_automatic_cleanup() -> None: print("=== Finalizer fires on collection ===") conn = DatabaseConnection() conn.execute("SELECT 1") conn = None gc.collect() print(f"Pool size after collection: {len(ConnectionPool._available)}") def demo_context_manager() -> None: print("\n=== Context manager with explicit close ===") with DatabaseConnection() as conn: conn.execute("SELECT 2") # close() called by __exit__ — finalizer detached, no double-release gc.collect() print(f"Pool size: {len(ConnectionPool._available)}") def demo_cycle_with_finalize() -> None: print("\n=== Finalizer fires despite reference cycle ===") class Node: def __init__(self, name: str) -> None: self.name = name self.other: Optional["Node"] = None # Capture name as a primitive — do not capture self node_name = name weakref.finalize(self, print, f"Node '{node_name}' collected") a = Node("A") b = Node("B") a.other = b b.other = a # Reference cycle a = b = None gc.collect() # Cyclic GC breaks the cycle; finalizers fire if __name__ == "__main__": demo_automatic_cleanup() demo_context_manager() demo_cycle_with_finalize()
detach() when a context manager handles explicit cleanup to prevent double-release.What Is a Weak Reference? — The One That Doesn't Count
You already know Python’s reference counting. Every strong reference increments the count. The garbage collector won't free an object until that count hits zero.
A weak reference does not increment the count. It's a pointer that says "I see this object, but I won't keep it alive." When the last strong reference dies, the object is freed, and your weak reference quietly becomes None (or calls a callback).
Why does this matter? Because strong references from caches, observers, or listener registries create accidental object retention. Your boss asks why the app consumes 8GB after four hours. You waste a day chasing cycles. A weak reference breaks that chain.
The `weakref` module gives you the tools: for a single weak pointer, ref() for transparent access, and proxy()WeakValueDictionary / WeakKeyDictionary for mappings that auto-clean. But not everything plays nice. Lists, dicts, tuples, and ints don't support weak references out of the box. You must subclass or use a container type that does.
Here's the mental model: strong references are ownership. Weak references are borrowed pointers with automatic invalidation.
// io.thecodeforge import weakref import sys class Image: def __init__(self, name): self.name = name img = Image("cached_photo.png") print(f"Strong ref count before weak ref: {sys.getrefcount(img) - 1}") # 1 w = weakref.ref(img) print(f"Strong ref count after weak ref: {sys.getrefcount(img) - 1}") # Still 1 print(f"Weak ref alive: {w() is not None}") # True del img # Kill strong reference print(f"Weak ref dead: {w() is None}") # True
w() is not None before dereferencing.WeakKeyDictionary — When Your Cache Keys Shouldn't Keep Objects Alive
Your coworkers love storing objects as dictionary keys for fast lookups. Sounds innocent. But every strong key reference pins that object in memory. If the key is a config object, a user session, or a database connection, you've just created a memory leak dressed up as a cache.
WeakKeyDictionary solves this. The keys are weak references. When all strong references to a key vanish, the entry is automatically removed. The values are still strongly held, so be careful — if your value references the key, you've built a cycle the GC will eventually collect, but not without cost.
When do you reach for this? The canonical use case is metadata annotations. You have a transient object (a request context, a file handle), and you want to attach extra data without modifying the class. A regular dict would pin your object forever. WeakKeyDictionary lets the object die naturally, taking its metadata with it.
One sharp edge: you cannot use built-in types like lists or tuples as keys because they don't support weak references. Subclass or use a simple wrapper. And never iterate over a WeakKeyDictionary expecting stable contents — keys vanish the moment their last strong reference goes out of scope.
// io.thecodeforge import weakref class RequestContext: def __init__(self, request_id): self.request_id = request_id # Annotate request contexts with timestamps without pinning them request_annotations = weakref.WeakKeyDictionary() def process_request(request_obj): request_annotations[request_obj] = {"status": "active", "started": "12:00"} print(f"Annotated request {request_obj.request_id}") ctx = RequestContext(42) process_request(ctx) print(f"Annotation exists: {request_annotations.get(ctx, 'not found')}") # Found del ctx # Strong reference gone print(f"After delete: {len(request_annotations)}") # 0 - auto-cleaned
WeakKeyDictionary with context managers. When the context exits and destroys the key object, the annotation map self-clears. No manual cleanup, no memory leaks.The callback Function — Your Escape Hatch for Object Death Events
A weak reference dying is silent by default. returns w()None, and you poll endlessly to check. That's wasteful. The callback parameter on flips the script — it fires when the referent is about to be destroyed.weakref.ref()
Here's the anatomy: you create weakref.ref(obj, my_callback). The callback receives the weak reference object as its only argument. Not the dying object — that's already gone by the time your code runs. This is perfect for cache invalidation, resource cleanup, or logging object death for debugging.
But don't get clever. Callbacks run during garbage collection, which can happen at unpredictable times (including during interpreter shutdown). Never call blocking I/O, acquire locks, or touch global state in a callback. You'll deadlock or segfault. Use weakref.finalize instead if you need guaranteed cleanup — it's safer and runs only once.
Callback gotcha: if your callback keeps a strong reference to the dying object via closure, you've created a cycle. The object will never die. The callback will never fire. Your production server will grind to a halt. Check your captured variables before deploying.
// io.thecodeforge import weakref class DatabaseConnection: def __init__(self, db_name): self.db_name = db_name def close(self): print(f"Closing connection to {self.db_name}") def on_death(weak_ref): # weak_ref() is None here - object already gone print(f"Connection object died. Logging for monitoring.") conn = DatabaseConnection("prod_db") w = weakref.ref(conn, on_death) del conn # Triggers callback immediately print("Main continues...")
The Observer Pattern That Leaked 2GB/hour
- Observer and pub-sub patterns without weak references are memory leaks waiting to happen. The publisher holds strong references to all subscriber callbacks, and bound methods hold strong references to their instances.
- Bound methods are temporary objects. Storing listener.handle_event in a WeakSet does not work — the bound method is collected immediately because nothing else holds a strong reference to it. Use weakref.WeakMethod for bound method weak references.
- WeakSet is the right container for listener objects themselves. Store the listener, call the method on emit. The WeakSet automatically skips collected objects during iteration — no None checks needed inside the loop.
- Do not assume out-of-scope means collected. If any strong reference remains anywhere in the process — event bus, log, cache, ORM identity map — the object persists. Use gc.get_referrers(obj) to find the unexpected holder.
- Attach weakref.finalize callbacks during development to verify objects are actually being collected when you expect. They cost nothing in production and save hours of debugging.
gc.collect() — objects that appear in gc.garbage are uncollectable cycles. For event bus leaks specifically, check every registration site and confirm it uses WeakSet or WeakMethod, not a plain list or set.gc.collect() explicitly, then check gc.get_objects() to see whether the suspect appears. For objects in reference cycles, the cyclic GC must run — gc.collect() triggers it. If the callback still does not fire after gc.collect(), the object is genuinely still referenced. Add gc.get_referrers(obj) output to your debug logging to identify the holder.python3 -c "
import gc
class MyClass: pass
obj = MyClass()
gc.collect()
refs = gc.get_referrers(obj)
for r in refs:
print(type(r).__name__, repr(r)[:120])
"python3 -c "
import gc, weakref
class MyClass: pass
obj = MyClass()
ref = weakref.ref(obj)
print('Before del:', ref() is not None)
del obj
gc.collect()
print('After del:', ref() is not None)
"gc.collect(), gc.get_referrers() will show the unexpected holder. Common culprits in the output: frame locals (a function still running), a list or set you forgot to clear, an ORM identity map, or a logging handler capturing the object.python3 -c "
import weakref
class Handler:
def handle(self): pass
h = Handler()
bad_ref = weakref.ref(h.handle)
print('weakref.ref result:', bad_ref()) # None — dead immediately
good_ref = weakref.WeakMethod(h.handle)
print('WeakMethod result:', good_ref()) # <bound method ...> — alive
"python3 -c "
import weakref, gc
class Handler:
def handle(self): print('called')
h = Handler()
ref = weakref.WeakMethod(h.handle)
ref()() # calls handle
del h
gc.collect()
print('After del:', ref()) # None — correctly dead
"python3 -c "
import weakref, gc
class MyClass:
def __init__(self, name): self.name = name
obj = MyClass('test')
weakref.finalize(obj, print, 'collected: test')
print('Before del')
del obj
gc.collect()
print('After gc.collect()')
"python3 -c "
import tracemalloc
tracemalloc.start()
# ... run suspect code ...
snap = tracemalloc.take_snapshot()
for stat in snap.statistics('lineno')[:10]:
print(stat)
"python3 -c "
import weakref, gc
class MyClass: pass
d = weakref.WeakValueDictionary()
obj = MyClass()
d['key'] = obj
print('Size before del:', len(d))
del obj
gc.collect()
print('Size after gc.collect():', len(d))
"python3 -c "
import weakref, gc
class MyClass: pass
obj = MyClass()
d = weakref.WeakValueDictionary()
d['key'] = obj
refs = gc.get_referrers(obj)
print('Referrers:', [type(r).__name__ for r in refs])
"gc.collect(), gc.get_referrers() will list the unexpected holder. Size not going to zero is not a bug in WeakValueDictionary — it means something else still holds the object strongly.| Container | Key Strength | Value Strength | Auto-Cleanup Trigger | Primary Use Case |
|---|---|---|---|---|
| weakref.ref(obj) | N/A | Weak | Object collected — ref() returns None | Single manual weak reference. Use when you need an explicit alive check or collection callback. |
| weakref.WeakMethod(method) | N/A | Weak (bound method) | Owner object collected — WeakMethod() returns None | Weak reference to a bound method. The only correct tool when storing callbacks like obj.handle_event. |
| WeakValueDictionary | Strong — must be immutable hashable | Weak | Value object collected — entry removed | ID-to-object identity caches. Value dies independently; key and entry vanish together. |
| WeakKeyDictionary | Weak | Strong | Key object collected — entry removed | Associating metadata with objects without preventing their collection. Key dies, metadata dies. |
| WeakSet | N/A (set elements) | Weak | Element object collected — removed from set | Listener registries storing listener objects. Iteration yields only live objects — no None check needed in loop. |
| weakref.proxy | N/A | Weak | Object collected — raises ReferenceError on access | Development convenience only. Raises ReferenceError on dead access — not suitable for production hot paths. |
Key takeaways
ref() is not None before use.d.items()) before iterating.Common mistakes to avoid
6 patternsStoring bound methods in a WeakSet expecting the registration to persist
Using weakref.ref on a bound method expecting it to stay alive
Assuming WeakValueDictionary entries clear immediately after dropping the last strong reference
gc.collect() calls but fail in production timing.gc.collect() runs. Do not depend on immediate cleanup in production code. For tests, call gc.collect() explicitly. For production, design so stale entries are harmless — WeakValueDictionary entries are always either live or absent, never stale.Passing self as an argument to weakref.finalize
gc.get_objects() longer than they should.Iterating WeakValueDictionary directly without snapshotting
d.items()). The list() call materialises the current entries before iteration begins.Using weakref.proxy in production without catching ReferenceError everywhere
weakref.ref() and an explicit None check at every access site. ref() and an if-check are faster than exception handling and predictable. proxy is a convenience for interactive use and prototyping, not a production pattern.Interview Questions on This Topic
Explain the difference between a regular reference and a weak reference in Python. When would you use a weak reference?
What is the difference between WeakValueDictionary and WeakKeyDictionary? Give a real-world example of each.
Why is the observer pattern a memory leak without weak references, and why does storing bound methods in a WeakSet not fix it?
What is the difference between weakref.finalize and the __del__ method? Why is finalize preferred in production?
detach() if you handle cleanup manually, and checked with the alive property.
For resource cleanup in production, prefer context managers for deterministic release, and weakref.finalize as a safety net for cases where context managers are not used. Reserve __del__ for simple debugging helpers on non-cyclic objects.How does CPython implement weak references without causing reference count overhead for every object?
ref() — returns None because wr_object is NULL.
Objects with no weak references have exactly zero overhead — no extra memory, no extra indirection, no allocation. The weakref list exists only if at least one weak reference was created.Frequently Asked Questions
A weak reference is a pointer to an object that does not stop the garbage collector from deleting it. You can access the object through the reference if it is still alive — ref() returns the object. If the object has been collected, ref() returns None. A regular reference, by contrast, keeps the object alive as long as you hold it.
Ask whether an object should be allowed to die independently of a container or registry that knows about it. If you have a cache that should not prevent eviction, an event bus that should not keep listeners alive, or a graph where back-references would create cycles, weak references are likely the answer. Operational symptoms: memory grows over time, restart fixes it temporarily, heap analysis shows objects that should be dead but are not.
No. Built-in types do not support weak references because their C-level type definition does not include tp_weaklistoffset — the slot that CPython uses to store the per-object weak reference list. This applies to all built-in types regardless of size or whether the value is cached. Custom classes automatically include this slot and support weak references by default. Attempting weakref.ref(42) raises TypeError.
Yes, but it is small. Accessing a weak reference requires dereferencing an extra pointer and checking for NULL. On CPython 3.12, this adds roughly 20-50ns per access compared to a direct attribute lookup — consistent with the benchmark in this article. For most applications, this is negligible. For tight loops processing millions of items, resolve the weak reference once outside the loop and keep a local strong reference for the loop body.
Start with gc.get_referrers(suspect_obj) to find what holds the object. Check every event bus and listener registration site — are they using list or set instead of WeakSet or WeakMethod? Enable gc.set_debug(gc.DEBUG_LEAK) and call gc.collect() to surface uncollectable cycles. Attach weakref.finalize(obj, print, 'collected') during development to verify when objects actually die. Profile object counts over time with tracemalloc.take_snapshot() — if a specific type grows unboundedly, that is your leak.
weakref.ref(obj) returns a callable that returns the object if alive or None if dead. You call it explicitly — ref() — and check for None before use. weakref.proxy(obj) returns an object that behaves syntactically like the original but raises ReferenceError on any access after the object is collected. proxy is convenient for interactive use and prototyping because you do not need the call syntax. In production, prefer ref() with an explicit None check — it is faster, predictable, and ReferenceError does not appear in unexpected stack frames under load.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Advanced Python. Mark it forged?
12 min read · try the examples if you haven't