skip to content

Why does a `weakref.WeakValueDictionary` cache of parsed log schemas keep emptying itself?

level: seniorimportance: should knowfreq 28%

answer

  1. The container owns nothing
  2. Weak means it never keeps values alive
  3. Something else must hold the value
  4. No strong reference, no entry
  5. Put a bounded strong cache underneath

basics

~20 s

Because its values are held only weakly. An entry survives exactly as long as something else keeps a strong reference to the value, so a freshly parsed schema that nothing else holds is collected before the next lookup.

solid answer

~40 s

A `weakref.WeakValueDictionary` is not a cache in the usual sense -- it is an **index of objects someone else is keeping alive**. Storing a value there adds no strong reference, so if the only other reference is a local variable in the function that built it, the value dies when that call returns and the entry disappears silently. In a log-ingest pipeline that parses one schema per service, the result is a permanent 0% hit rate that reads as a broken cache rather than a lifetime bug. The fix is to give the values a strong floor: a bounded LRU or a fixed-size deque of recent entries alongside the weak mapping. Two further traps -- values that cannot be weakly referenced raise `TypeError` on insert, and `len()` is only advisory.

code

python · 17 lines
python
import weakref


class Schema:
    def __init__(self, service):
        self.service = service


cache = weakref.WeakValueDictionary()
cache["svc-17"] = Schema("svc-17")     # nothing else holds it
print("after insert:", len(cache))

kept = Schema("svc-17")
cache["svc-17"] = kept                 # a strong reference survives
print("with a strong ref:", len(cache))
del kept
print("after dropping it:", len(cache))

go deeper

for a junior

Take away the core rule: a weak-valued mapping never keeps its values alive. If nothing else refers to the value, the entry disappears on its own, so it is not a substitute for an ordinary cache.

for a middle

Explain the mechanics of the disappearance -- the value's refcount reaches zero, it is collected, and the entry is removed -- and know that values must be weak-referenceable, so strings, ints and tuples raise TypeError on insert.

for a senior

Diagnose it end to end: near-zero hit rate with no errors, confirm nothing else holds the values, then design the retention policy explicitly with a bounded strong structure under the weak index. Handle the mirror-image case of a weak container that never shrinks.

for a principal

Own the policy: caching is a memory budget, and a container whose retention emerges from unrelated code's reference graph is not a budget. Decide where in the system lifetime is explicit, and reserve weak mappings for canonicalization.

### What the container actually promises `weakref.WeakValueDictionary` maps ordinary strong keys to **weakly referenced values**. The entry vanishes automatically when its value is garbage collected. The sibling containers behave analogously: `weakref.WeakKeyDictionary` holds keys weakly, and `weakref.WeakSet` holds members weakly. Read that promise carefully and the failure becomes obvious. The mapping never keeps a value alive. It is a way to *find* an object that something else already owns. Used as a memoization cache, where the mapping is meant to be the reason the object still exists, it does nothing at all. ### The failure in practice Picture a log-ingest pipeline that parses one schema object per emitting service across a 17-service dependency graph, and caches them by service name in a `weakref.WeakValueDictionary`. The parse function builds the schema, stores it, returns it. The caller uses it for one batch and drops it. The refcount hits zero, the value is collected, the mapping quietly removes the entry. The next batch parses all 17 schemas again. Nothing raises, no log line appears, `len(cache)` reads zero, and the symptom is a cache that silently truncates itself to empty while CPU sits in the parser. It is a slow, invisible bug precisely because the container is doing exactly what it documents. The reverse mistake produces the opposite symptom. If some long-lived structure -- a registry, a logging handler, a closure captured in a callback -- holds a strong reference to every value, nothing is ever collected and the "weak" cache grows without bound. Weak containers only shrink when the rest of the program lets go, so unbounded growth means you have an ownership problem elsewhere, and `weakref.getweakrefcount` plus a look at what refers to a sample value is where the investigation starts. ### Fixing it The cure is to decide, explicitly, what keeps values alive and for how long. The common pattern is a **strong floor under a weak index**: keep a bounded strong structure -- a fixed-size `collections.deque` of recently used values, or a `functools.lru_cache`-style bounded mapping -- so the last N entries cannot be collected, and let the weak mapping catch everything beyond that which the wider program still happens to hold. That gives a real hit rate for hot keys with a hard memory ceiling, and it makes the retention policy a number you chose rather than an emergent property of unrelated code. If what you actually wanted is a plain bounded cache, use a bounded strong cache and stop there; weakness buys you nothing. The genuine use for weak-valued mappings is **canonicalization**: an interning table or identity map where the requirement is "if this object exists anywhere, everyone must get the same one", and where the table must not be the thing that keeps it alive. There the automatic eviction is the whole point. ### Two further traps **Not every value can be stored.** The value must support weak references, so a `str`, `int`, `tuple`, `list` or `dict` value raises `TypeError: cannot create weak reference to ...` at insertion time, as does an instance of a class whose `__slots__` omits `'__weakref__'`. A cache keyed by service name and holding plain strings simply cannot be built this way. **Size and iteration are advisory.** Because entries disappear whenever the collector runs, `len()` is a snapshot that may be stale by the next line, and a `for` loop over the mapping can see entries removed underneath it. The containers guard against mutation during iteration internally by deferring removals until the iteration finishes, but that also means the mapping can hold entries fractionally longer than you expect while iterating. In a threaded program, the safest habit is to materialize a list of the values you care about -- which incidentally takes strong references and pins them for the duration -- rather than looping over the live mapping and hoping. ### How to talk about this The short diagnosis an interviewer wants: the container holds values weakly, nothing else was holding them, so they were collected immediately and the entries went with them. The senior half of the answer is what you do next -- name the retention policy explicitly, put a bounded strong structure underneath, and reserve weak-valued mappings for identity maps where automatic eviction is the requirement rather than an accident.

  • When is a `weakref.WeakValueDictionary` genuinely the right container?
    When you need canonicalization rather than caching: an interning table or identity map that guarantees everyone asking for the same key gets the same live object, and that must not itself keep those objects alive. The automatic eviction is the requirement. Any time the mapping is meant to be the *reason* an object still exists, you want a bounded strong cache instead.
  • The opposite symptom: a weak-valued mapping that never shrinks. What does that tell you?
    That something else in the process holds strong references to every value -- a registry, a module-level list, a bound method stored as a callback, a closure, or a live traceback. A weak container shrinks only when the rest of the program lets go, so unbounded growth is an ownership bug elsewhere. Sample a value and inspect what refers to it.
  • Why can `len()` on a weak-valued mapping mislead you?
    Entries disappear whenever their values are collected, which can happen between any two statements, so the count is a snapshot with no guarantee of remaining true. The containers defer removals while an iteration is in progress to keep loops safe, which can also make the mapping look slightly larger than it really is. Never branch on the count as if it were stable.
  • What happens if you store a plain string as a value in a `weakref.WeakValueDictionary`?
    It raises `TypeError` at insertion, because `str` instances cannot be weakly referenced -- they have no weak-reference slot. The same applies to `int`, `tuple`, `list`, `dict` and to instances of classes whose `__slots__` omits `'__weakref__'`. Only weak-referenceable values can be stored, which rules out a large share of the values people first try to cache.

It is a coat-check ticket that only works while the coat's owner is still in the building: the cloakroom never keeps the coat, it just helps you find one somebody else is holding onto.

saying these in an interview costs you the question

  • Thinks the mapping keeps its values alive
  • Uses it as a general-purpose memoization cache
  • Blames the garbage collector for being too eager
  • Expects `len()` to be stable across statements
  • Tries to store strings or tuples as the values
  • Adds `gc.disable()` to make entries survive

context