A normal dictionary cache keeps its keys and values alive. Sometimes that is exactly the bug: metadata should remain available while an object is used elsewhere, but the metadata table should not become its owner. Weak references create an edge that does not keep its target alive.

Weakness is a lifetime policy, not a general memory limit. An entry disappears according to target reachability, not age, size, load, or business importance. A weak cache can forget immediately; it can also retain everything forever when some unrelated subsystem holds the targets.

Python guarantee. A weak reference does not keep its referent alive. Calling it returns the object while alive and None afterward. Only weak-referenceable objects participate, and callback timing is not a portable cleanup schedule.

Version note. Experiments ran on standard GIL-enabled CPython 3.14.7. Prompt disappearance of acyclic objects follows CPython reference counting and is not guaranteed by Python or other implementations. Callback ordering, GC timing, and weak-container internals are implementation/version details.

Experiment 1: a weak edge does not retain

import gc
import weakref


class Document:
    pass


document = Document()
reference = weakref.ref(document)
print(reference() is document)

del document
gc.collect()
print(reference() is None)

reference() is the safe dereference operation. Do not test reference() is not None and then call reference() again: another thread or re-entrant action could remove the final strong reference between calls. Capture once with target = reference() and use target while that local strong reference exists.

Not every object supports weakness

Most ordinary user-class instances do. Many built-in values, including plain lists and dictionaries, do not. Subclassing some built-ins can add support. Slotted classes must include __weakref__ unless a base already supplies it.

Experiment 2: support is part of a class API

import weakref


class Closed:
    __slots__ = ("name",)


class Open:
    __slots__ = ("name", "__weakref__")


for value in ([], Closed(), Open()):
    try:
        weakref.ref(value)
    except TypeError:
        print(type(value).__name__, "no")
    else:
        print(type(value).__name__, "yes")

If framework infrastructure needs weak references, test this capability when introducing slots or extension types. Wrapping a non-weak-referenceable object in a weak-referenceable holder only helps if something strongly retains the holder for the desired lifetime.

Callbacks observe loss; they do not own cleanup

weakref.ref(target, callback) invokes the callback after the referent is being finalized and passes the dead weak-reference object, not the referent. Holding the referent in the callback closure defeats the design by creating a strong path.

Exceptions raised by weak-reference callbacks are reported to standard error and cannot propagate normally. Keep callbacks short, non-blocking, and defensive. Do not perform critical resource cleanup solely there; use context managers and explicit close.

Experiment 3: callback receives a dead reference

import gc
import weakref


events = []


class Item:
    pass


item = Item()
reference = weakref.ref(item, lambda ref: events.append(ref() is None))
del item
gc.collect()

print(events)
print(reference())

On the tested CPython build this records [True]. Collection may be delayed elsewhere, so applications must tolerate the callback occurring later or during interpreter shutdown.

WeakValueDictionary: values may disappear

A weak-value mapping strongly owns keys and weakly refers to values. It is useful for canonicalization and lookup tables where callers own returned objects. Looking up an entry briefly strengthens the result through the local variable.

Experiment 4: an identity map that does not own records

import gc
import weakref


class Record:
    def __init__(self, key):
        self.key = key


records = weakref.WeakValueDictionary()
record = Record("r1")
records[record.key] = record
print(list(records))

del record
gc.collect()
print(list(records))

This is not a durable repository: an object can vanish immediately after its last outside use. A get-or-create operation also needs concurrency control if duplicate live instances violate invariants. Weakness solves retention, not atomicity.

Iteration over weak containers can change as targets die. Materialize a snapshot when stable traversal matters, understanding that the snapshot's objects may then be strongly retained for its duration.

WeakKeyDictionary: attach metadata without ownership

A weak-key mapping removes an entry when its key dies while retaining values strongly. It is useful when objects cannot or should not receive attributes. Keys must be weak-referenceable and hashable.

Equality introduces a subtlety: inserting an equal but non-identical key can replace the value while the original stored weak key still controls lifetime. Identity-oriented metadata should use key types whose equality semantics match that intention.

Experiment 5: equal keys expose lifetime semantics

import gc
import weakref


class Key:
    def __init__(self, value):
        self.value = value

    def __hash__(self):
        return hash(self.value)

    def __eq__(self, other):
        return isinstance(other, Key) and self.value == other.value


first = Key(1)
second = Key(1)
metadata = weakref.WeakKeyDictionary({first: "first"})
metadata[second] = "second"
print(len(metadata), metadata[first])

del first
gc.collect()
print(len(metadata))

The mapping can become empty even while second lives because equal-key replacement did not necessarily replace the controlling key object. This documented weak-mapping behavior is one reason not to treat weak containers as ordinary dictionaries with free eviction.

WeakSet tracks membership without ownership

WeakSet is appropriate for observers, live instances, or subscribers that are owned elsewhere. It prevents a registry from becoming the reason every subscriber survives forever.

Experiment 6: a subscriber registry shrinks

import gc
import weakref


class Listener:
    pass


listeners = weakref.WeakSet()
first = Listener()
second = Listener()
listeners.update((first, second))
print(len(listeners))

del first
gc.collect()
print(len(listeners), second in listeners)

Weak registries do not replace unsubscribe logic when deterministic behavior matters. During dispatch, take strong local references to selected listeners. Define thread synchronization around add, remove, and traversal rather than assuming container operations form an application transaction.

WeakMethod handles bound methods

Fetching instance.method creates a bound method object that itself may be short-lived. A plain weak reference to that temporary can die even while the instance lives. weakref.WeakMethod reconstructs the bound method while both instance and function survive.

Experiment 7: weakly retain a callback target

import gc
import weakref


class Receiver:
    def handle(self):
        return "handled"


receiver = Receiver()
method = weakref.WeakMethod(receiver.handle)
print(method()())

del receiver
gc.collect()
print(method() is None)

Event systems commonly need a mixture of functions, callable objects, and bound methods, so one weak-reference strategy may not cover every callback shape. An explicit subscription token with deterministic cancellation is often clearer.

finalize avoids common callback traps

weakref.finalize(obj, func, *args) keeps the finalizer alive until it runs and invokes func after obj becomes unreachable. The callback, arguments, and keyword arguments must not strongly reference obj, directly or through a bound method, or collection can be prevented.

Finalizers are fallback cleanup. They may run late, at shutdown under constrained conditions, or not at all if the process exits abruptly. Context managers remain the primary resource-lifetime API.

Experiment 8: make fallback action independent

import gc
import weakref


events = []


class Lease:
    pass


lease = Lease()
finalizer = weakref.finalize(lease, events.append, "released")
print(finalizer.alive)

del lease
gc.collect()
print(events, finalizer.alive)

Capturing only the independent string keeps the ownership graph clean. Calling the finalizer explicitly is supported and idempotently marks it dead, which can integrate fallback behavior with an explicit close path.

Cache policy must match correctness

Weak caches answer: "retain this only while something else retains a particular object." They do not answer: "keep the last 10,000 entries," "expire after five minutes," or "stay under 200 MB." Use bounded LRU/LFU, TTL, or weighted eviction for those requirements. Such caches strongly retain entries until policy removes them.

functools.lru_cache strongly retains arguments and results. Decorating an instance method can keep self alive through cache keys. Provide explicit invalidation, move caching to a correctly scoped owner, or use a weak-key strategy only if disappearance semantics are acceptable.

Cache values can strongly point back to weak keys, preventing key death through the mapping's strong value edge. Draw the complete graph. Weakening one edge does not help when another path remains.

Measure hit rate, entry count, retained bytes, and regeneration cost. A weak cache's hit rate can vary with unrelated reachability and implementation collection timing. If correctness relies on an entry's presence, it is not a cache and should be strongly owned.

Designing a dependable weak facility

Begin by naming the strong owner. If nobody is responsible for retaining a target, a weak registry can empty immediately and appear broken. In a user-interface observer system, widgets may own subscriptions while a controller weakly indexes widgets. In an identity map, active domain operations own records while the map only enables reuse. Without that ownership sentence, weakening a dictionary is guesswork.

Then identify every path back to the target. A weak key paired with a value closure that captures the key is effectively strong. A weak listener stored alongside a bound cleanup callback may retain the listener through the callback. Debug these systems by drawing nodes for keys, values, closures, finalizers, tasks, and framework registries rather than staring only at container types.

Define miss behavior as part of the API. Weak disappearance can happen between operations, so callers must tolerate recreation or an absent lookup. If recreation has side effects, use a lock and reconsider whether reachability-driven eviction is valid. If object identity must remain unique while database rows exist, durable strong ownership belongs in a unit of work, not an opportunistic weak map.

Thread safety is not supplied by weakness. A lookup that returns an object makes it strongly reachable in that local scope, but check-then-create still races. Iteration and callbacks can interleave with application actions. Protect compound operations with the same synchronization you would use for a normal mapping and keep weak callbacks outside critical lock paths where possible.

Observability also must avoid changing the result. Materializing all values to count or inspect them temporarily retains those values. Debuggers and logs can extend lifetime. Report weak-container length as a transient gauge, and pair it with creation and miss counters rather than treating one sample as a stable inventory.

Finally, test on the supported implementation without encoding prompt CPython collection as a business promise. Force gc.collect() only in narrow lifetime tests, release all accidental test references, and assert eventual allowed behavior. Production correctness should remain valid if collection is delayed indefinitely.

Document why each weak edge exists and what recreates a missing target. Future maintainers otherwise tend to replace surprising disappearance with a strong dictionary, restoring the original retention problem. A focused regression test should prove both halves of the policy: entries remain while an external owner exists, and the registry does not become an owner after that reference is released.

Exercises

  1. Build a weak-value canonicalization map and define whether simultaneous misses may create duplicates.
  2. Create a weak-key mapping whose value points to its key; draw why the entry remains.
  3. Replace an observer list with WeakSet, then add deterministic unsubscribe and safe dispatch snapshots.
  4. Demonstrate how a bound-method finalizer accidentally retains its object, then replace it with independent arguments.
  5. Compare a weak cache with a bounded LRU under a workload and explain which policy the product requires.

Keep this model

A weak reference is a non-owning graph edge. Weak-value mappings let callers own values; weak-key mappings attach strongly held metadata to externally owned keys; weak sets track externally owned members; weak methods model ephemeral bound callbacks. Every form has equality, iteration, concurrency, and timing consequences.

Use weakness when reachability is genuinely the eviction policy. Use explicit bounded policies when capacity or age matters, and explicit cleanup for resources. Most weak-reference bugs disappear when the entire retaining graph, rather than one container declaration, is drawn.

Primary sources