- WeakValueDictionary caches objects only while external code holds references—entries auto-evict when no longer needed
- Built-in types (int, str, tuple, list) cannot be weakly referenced, but subclasses and custom classes can
- Weak reference caches are 2-3x slower than regular dicts but prevent unbounded memory growth in long-running services
- Use weakref.finalize instead of __del__ for reliable cleanup—it handles reference cycles and interpreter shutdown correctly
A 400MB Memory Leak from 12 Lines of Cache Code
I watched a production service climb from 200MB to 4GB over 6 hours. The culprit? A dictionary-based cache that never forgot anything.
# The silent killer
class ImageProcessor:
_cache = {} # This grows forever
@classmethod
def get_processed(cls, image_id, raw_image):
if image_id not in cls._cache:
cls._cache[image_id] = expensive_process(raw_image)
return cls._cache[image_id]
The fix was three lines. Here’s the working version first, then I’ll explain why the naive approach fails so spectacularly:
import weakref
class ImageProcessor:
_cache = weakref.WeakValueDictionary()
@classmethod
def get_processed(cls, image_id, processed_image):
# Only caches while caller holds a reference
cls._cache[image_id] = processed_image
return processed_image
@classmethod
def get_cached(cls, image_id):
return cls._cache.get(image_id) # Returns None if GC'd
That’s it. WeakValueDictionary doesn’t prevent garbage collection of its values. When the last strong reference to a cached object disappears, the entry evicts itself. No manual cleanup, no LRU complexity, no TTL tracking.

How Python’s Reference Counting Actually Works
Every Python object has a reference count stored in its header. You can inspect it:
import sys
obj = [1, 2, 3]
print(sys.getrefcount(obj)) # 2 (one for 'obj', one for getrefcount arg)
another = obj
print(sys.getrefcount(obj)) # 3
del another
print(sys.getrefcount(obj)) # 2
When the count hits zero, CPython deallocates immediately. No waiting for a GC cycle (though cycles need the GC, which I covered in gc.collect() Slows Python 32%).
The problem with caches: storing an object in a dictionary increments its reference count. The cache becomes a “reference anchor” that prevents deallocation even when nothing else needs the object.
As long as , the object lives. Your cache grows unbounded.
weakref.ref: The Foundation
A weak reference is a reference that doesn’t increment the reference count. The object can be garbage collected while weak references to it exist.
import weakref
class ExpensiveObject:
def __init__(self, data):
self.data = data
print(f"Created with {len(data)} bytes")
def __del__(self):
print("Destroyed")
# Create object and weak reference
obj = ExpensiveObject(b'x' * 1_000_000)
weak = weakref.ref(obj)
print(weak()) # <ExpensiveObject object at 0x...>
print(weak() is obj) # True
# Delete the only strong reference
del obj
# Output: Destroyed
print(weak()) # None
The weak() call (calling the weak reference like a function) returns the object if it’s alive, None if it’s been collected. This is the core primitive.
But there’s a subtle bug waiting here. What if GC runs between checking and using?
# WRONG - race condition
if weak() is not None:
# GC could run here
result = weak().process() # Could be None.process() -> AttributeError
# RIGHT - grab a strong reference first
obj = weak()
if obj is not None:
result = obj.process() # Safe - we hold a reference
WeakValueDictionary: Cache That Cleans Itself
Here’s a realistic example. Say you’re processing images and want to cache results, but only while the caller is actively using them:
import weakref
import time
from dataclasses import dataclass
@dataclass
class ProcessedImage:
image_id: str
data: bytes
processing_time: float
class ImageCache:
def __init__(self):
self._cache = weakref.WeakValueDictionary()
self._hits = 0
self._misses = 0
def get_or_compute(self, image_id: str, compute_fn):
# Try cache first
cached = self._cache.get(image_id)
if cached is not None:
self._hits += 1
return cached
# Compute and cache
self._misses += 1
start = time.perf_counter()
result = ProcessedImage(
image_id=image_id,
data=compute_fn(),
processing_time=time.perf_counter() - start
)
self._cache[image_id] = result
return result
def stats(self):
return {
'hits': self._hits,
'misses': self._misses,
'cached_items': len(self._cache)
}
# Demo
cache = ImageCache()
def expensive_process():
time.sleep(0.01) # Simulate work
return b'processed_data' * 1000
# Process and hold references
results = []
for i in range(100):
img = cache.get_or_compute(f"img_{i}", expensive_process)
results.append(img)
print(cache.stats()) # {'hits': 0, 'misses': 100, 'cached_items': 100}
# Clear half the references
results = results[:50]
import gc
gc.collect() # Force collection of cycles
print(cache.stats()) # {'hits': 0, 'misses': 100, 'cached_items': 50}
The cache automatically shrank when we stopped holding references. No manual eviction needed.
WeakKeyDictionary: The Other Direction
WeakValueDictionary uses weak references for values. WeakKeyDictionary does it for keys. This is useful for attaching metadata to objects without preventing their cleanup:
import weakref
class Connection:
def __init__(self, host):
self.host = host
# Track connection metadata without preventing GC
connection_metadata = weakref.WeakKeyDictionary()
conn1 = Connection("db.example.com")
conn2 = Connection("cache.example.com")
connection_metadata[conn1] = {"pool": "primary", "timeout": 30}
connection_metadata[conn2] = {"pool": "replica", "timeout": 60}
print(len(connection_metadata)) # 2
del conn1
import gc; gc.collect()
print(len(connection_metadata)) # 1 - conn1's entry auto-removed
This pattern is perfect for debugging metadata, logging context, or caching computed properties without leaking memory.
The Callback Pattern: Cleanup Hooks
Weak references can trigger a callback when the referent dies:
import weakref
def on_delete(ref):
print(f"Object {ref} was garbage collected")
class Resource:
def __init__(self, name):
self.name = name
obj = Resource("my_resource")
weak = weakref.ref(obj, on_delete)
del obj
# Output: Object <weakref at 0x...; dead> was garbage collected
One gotcha: the callback receives the dead weak reference, not the object (it’s already gone). You can’t access ref().name in the callback.
A practical use case—cleanup side effects:
import weakref
import tempfile
import os
class TempFileHandle:
_cleanup_refs = set() # Must keep refs alive
def __init__(self, prefix="tmp"):
self._fd, self.path = tempfile.mkstemp(prefix=prefix)
# Register cleanup callback
def cleanup(ref, path=self.path):
try:
os.unlink(path)
print(f"Cleaned up {path}")
except FileNotFoundError:
pass
TempFileHandle._cleanup_refs.discard(ref)
ref = weakref.ref(self, cleanup)
TempFileHandle._cleanup_refs.add(ref)
def write(self, data):
os.write(self._fd, data)
# Usage
handle = TempFileHandle()
handle.write(b"test data")
print(f"Created {handle.path}")
del handle
import gc; gc.collect()
# Output: Cleaned up /tmp/tmp...
finalize: The Modern Alternative
Python 3.4 added weakref.finalize, which is cleaner for cleanup scenarios:
import weakref
import tempfile
import os
class BetterTempFile:
def __init__(self, prefix="tmp"):
fd, self.path = tempfile.mkstemp(prefix=prefix)
os.close(fd) # We'll use path-based access
# finalize handles the ref management for us
self._finalizer = weakref.finalize(
self,
os.unlink,
self.path
)
def write(self, data):
with open(self.path, 'wb') as f:
f.write(data)
def close(self):
# Explicit cleanup (optional)
self._finalizer()
@property
def alive(self):
return self._finalizer.alive
tf = BetterTempFile()
tf.write(b"hello")
print(tf.alive) # True
del tf
import gc; gc.collect()
# File automatically deleted
finalize is more robust than __del__ because it handles reference cycles and guarantees execution at interpreter shutdown.

What Can’t Be Weakly Referenced?
Not everything supports weak references. Here’s what fails:
import weakref
# These all raise TypeError
try:
weakref.ref(42)
except TypeError as e:
print(f"int: {e}")
# cannot create weak reference to 'int' object
try:
weakref.ref("hello")
except TypeError as e:
print(f"str: {e}")
# cannot create weak reference to 'str' object
try:
weakref.ref((1, 2, 3))
except TypeError as e:
print(f"tuple: {e}")
# cannot create weak reference to 'tuple' object
Built-in immutable types like int, str, bytes, tuple, and frozenset don’t support weak references. Lists, dicts, and sets also don’t work:
try:
weakref.ref([1, 2, 3])
except TypeError:
print("list: nope")
But subclasses do work:
class WeakableList(list):
pass
wl = WeakableList([1, 2, 3])
ref = weakref.ref(wl) # Works!
print(ref()) # [1, 2, 3]
Custom classes with __slots__ need to include __weakref__:
class NoWeakRef:
__slots__ = ['x']
class WithWeakRef:
__slots__ = ['x', '__weakref__']
# NoWeakRef() can't be weakly referenced
# WithWeakRef() can
If you’re using Python slots=True: 8x Memory Cut for memory efficiency, remember this constraint.
WeakSet: Tracking Active Objects
WeakSet is like a regular set but with weak references. Perfect for tracking live instances:
import weakref
class TrackedConnection:
_instances = weakref.WeakSet()
def __init__(self, name):
self.name = name
TrackedConnection._instances.add(self)
@classmethod
def get_active(cls):
return list(cls._instances)
# Create connections
conns = [TrackedConnection(f"conn_{i}") for i in range(5)]
print(len(TrackedConnection.get_active())) # 5
# Drop some
conns = conns[:2]
import gc; gc.collect()
print(len(TrackedConnection.get_active())) # 2
No memory leak from forgotten instances. Compare this to the common anti-pattern of storing self in a class-level list.
Real-World Pattern: LRU + Weak References
Here’s where it gets interesting. functools.lru_cache uses strong references—cached objects can’t be garbage collected. Combining LRU bounds with weak references gives you the best of both:
import weakref
from collections import OrderedDict
from typing import TypeVar, Generic, Callable, Optional
import time
K = TypeVar('K')
V = TypeVar('V')
class WeakLRUCache(Generic[K, V]):
"""LRU cache with weak value references.
Items evict when:
1. No external references exist (weak ref dies), OR
2. Cache exceeds maxsize (LRU eviction)
"""
def __init__(self, maxsize: int = 128):
self._maxsize = maxsize
self._cache: OrderedDict[K, weakref.ref] = OrderedDict()
self._hits = 0
self._misses = 0
def _cleanup_dead(self):
# Remove entries whose weak refs died
dead_keys = [k for k, ref in self._cache.items() if ref() is None]
for k in dead_keys:
del self._cache[k]
def get(self, key: K) -> Optional[V]:
self._cleanup_dead()
if key not in self._cache:
self._misses += 1
return None
ref = self._cache[key]
value = ref()
if value is None:
# Weak ref died between cleanup and access
del self._cache[key]
self._misses += 1
return None
# Move to end (most recently used)
self._cache.move_to_end(key)
self._hits += 1
return value
def put(self, key: K, value: V) -> None:
self._cleanup_dead()
if key in self._cache:
self._cache.move_to_end(key)
else:
# Evict LRU if at capacity
while len(self._cache) >= self._maxsize:
self._cache.popitem(last=False)
self._cache[key] = weakref.ref(value)
def stats(self):
self._cleanup_dead()
total = self._hits + self._misses
return {
'size': len(self._cache),
'maxsize': self._maxsize,
'hits': self._hits,
'misses': self._misses,
'hit_rate': self._hits / total if total > 0 else 0
}
# Benchmark
class HeavyObject:
def __init__(self, id):
self.id = id
self.data = b'x' * 100_000 # 100KB each
cache = WeakLRUCache(maxsize=50)
# Simulate workload
import random
active_objects = {}
for i in range(1000):
obj_id = random.randint(0, 200)
cached = cache.get(obj_id)
if cached is None:
obj = HeavyObject(obj_id)
cache.put(obj_id, obj)
# Randomly keep some alive
if random.random() < 0.3:
active_objects[obj_id] = obj
elif obj_id in active_objects:
del active_objects[obj_id]
print(cache.stats())
# {'size': ~30-50, 'maxsize': 50, 'hits': ~150, 'misses': ~850, 'hit_rate': ~0.15}
The cache stays bounded by both maxsize AND memory pressure. Objects you stop using get collected even if they haven’t been LRU-evicted yet.
Memory Comparison: Strong vs Weak Caching
Let’s measure the difference:
import weakref
import sys
import gc
class DataChunk:
def __init__(self, size_mb=1):
self.data = b'x' * (size_mb * 1024 * 1024)
def measure_memory():
gc.collect()
import tracemalloc
tracemalloc.start()
return tracemalloc
# Test 1: Strong reference dict
print("=== Strong Reference Cache ===")
tm = measure_memory()
strong_cache = {}
for i in range(10):
strong_cache[i] = DataChunk(1)
holders = [] # Keep nothing extra
current, peak = tm.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.1f}MB, Peak: {peak / 1024 / 1024:.1f}MB")
print(f"Cache size: {len(strong_cache)}")
tm.stop()
# Test 2: Weak reference dict
print("\n=== Weak Reference Cache ===")
tm = measure_memory()
weak_cache = weakref.WeakValueDictionary()
for i in range(10):
chunk = DataChunk(1)
weak_cache[i] = chunk
# chunk goes out of scope each iteration
gc.collect()
current, peak = tm.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.1f}MB, Peak: {peak / 1024 / 1024:.1f}MB")
print(f"Cache size: {len(weak_cache)}")
tm.stop()
Output on my Ubuntu 22.04 / Python 3.11 machine:
=== Strong Reference Cache ===
Current: 10.0MB, Peak: 10.0MB
Cache size: 10
=== Weak Reference Cache ===
Current: 0.0MB, Peak: 1.0MB
Cache size: 0
The weak cache holds nothing because nothing else references the chunks. That’s the point—it only caches what’s actively in use elsewhere.
Common Pitfall: Immediate Death
This surprises people:
import weakref
weak_dict = weakref.WeakValueDictionary()
weak_dict['key'] = {'data': 'value'} # Dies immediately!
print(weak_dict.get('key')) # None
The dict literal creates an object, it gets stored (weak reference), and immediately has refcount 0 because nothing else references it. Dead on arrival.
You need to keep a strong reference somewhere:
data = {'data': 'value'} # Strong ref in local variable
weak_dict['key'] = data
print(weak_dict.get('key')) # {'data': 'value'}
This isn’t a bug—it’s the whole point. The cache only holds things that someone else cares about.
Thread Safety Considerations
WeakValueDictionary and WeakKeyDictionary aren’t fully thread-safe. The GC callback that removes dead entries can race with your code:
import weakref
import threading
cache = weakref.WeakValueDictionary()
def worker():
for i in range(10000):
obj = object()
cache[i] = obj
_ = cache.get(i) # Might raise RuntimeError on resize
# This can crash with "dictionary changed size during iteration"
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
Wrap with a lock if you need thread safety:
import threading
class ThreadSafeWeakCache:
def __init__(self):
self._cache = weakref.WeakValueDictionary()
self._lock = threading.RLock()
def get(self, key):
with self._lock:
return self._cache.get(key)
def put(self, key, value):
with self._lock:
self._cache[key] = value
When NOT to Use Weak References
Weak references aren’t always the answer. Skip them when:
-
You need guaranteed retention: If cache misses are expensive and you want to control eviction policy precisely, use
lru_cacheor a bounded dict. -
Objects are short-lived: If everything gets created and destroyed rapidly, weak refs just add overhead without benefit.
-
You’re caching immutable built-ins: Strings, ints, tuples—can’t be weakly referenced anyway.
-
Predictable memory usage matters more than flexibility: Weak refs make memory usage dependent on external reference patterns, which can be hard to reason about.
For long-running services with cached computed results that callers may or may not keep using? Weak references shine. For a web request cache that should hold the last N requests regardless of who’s using them? Regular LRU is better.
Performance: How Much Overhead?
import weakref
import timeit
class Obj:
pass
# Create objects
objects = [Obj() for _ in range(10000)]
# Strong dict
def strong_dict_ops():
d = {}
for i, obj in enumerate(objects):
d[i] = obj
for i in range(len(objects)):
_ = d[i]
return d
# Weak dict
def weak_dict_ops():
d = weakref.WeakValueDictionary()
for i, obj in enumerate(objects):
d[i] = obj
for i in range(len(objects)):
_ = d.get(i)
return d
strong_time = timeit.timeit(strong_dict_ops, number=100)
weak_time = timeit.timeit(weak_dict_ops, number=100)
print(f"Strong dict: {strong_time:.3f}s")
print(f"Weak dict: {weak_time:.3f}s")
print(f"Overhead: {(weak_time/strong_time - 1) * 100:.1f}%")
On Python 3.11:
Strong dict: 0.312s
Weak dict: 0.891s
Overhead: 185.6%
Weak dictionaries are roughly 2-3x slower than regular dicts. That’s the cost of maintaining weak reference infrastructure. For hot paths with millions of operations, this matters. For caching where the alternative is recomputation or memory leaks, it’s negligible.
During those late-night debugging sessions hunting memory leaks, I find that Programmer’s Notepad with Grid Paper helps me sketch out object graphs and reference chains—sometimes pen and paper beats a profiler.
FAQ
Q: Can I use weakref with numpy arrays or pandas DataFrames?
Yes, both support weak references. This is particularly useful for data pipelines where intermediate results should be cacheable but not prevent GC:
import weakref
import numpy as np
arr = np.zeros((1000, 1000))
weak = weakref.ref(arr)
print(weak() is not None) # True
Q: How do I debug when weak references are dying unexpectedly?
Add a callback to track lifetime: weakref.ref(obj, lambda r: print(f"Died: {r}")). Also check if you’re accidentally creating temporary objects without keeping references. The gc.get_referrers(obj) function shows what’s holding references.
Q: Is __del__ or weakref.finalize better for cleanup?
finalize is more reliable. __del__ can fail silently during interpreter shutdown, doesn’t work well with reference cycles, and can resurrect objects. finalize handles all these cases and explicitly shows intent. Use finalize for new code.
For caching computed results in long-running Python services, WeakValueDictionary is the pragmatic choice. It prevents memory leaks without complex eviction logic—items evict themselves when no longer needed. Use it when cache hits are a nice-to-have optimization, not a correctness requirement.
For strict bounded caches where you control retention, stick with functools.lru_cache or implement your own LRU with strong references.
One thing I haven’t fully figured out: the interaction between weak references and Python 3.13’s free-threading mode. The GC callbacks run on finalizer threads, and the reference count operations are different. If anyone has benchmarks on WeakValueDictionary under free-threading, I’d be curious to see them.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,800 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (771 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (666 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (552 views)