skip to content

How do you invalidate a value cached by functools.cached_property on one instance?

level: juniorimportance: should knowfreq 28%

answer

  1. There is no clear() or invalidate() method
  2. The cache is just an instance attribute
  3. One statement removes it
  4. del raises when nothing was cached yet
  5. obj.__dict__.pop with a default is safe

basics

~20 s

Delete the attribute — del obj.attr — which removes the entry from the instance __dict__ so the next read recomputes. If nothing was cached yet, that raises AttributeError, so guard it or pop from obj.__dict__ with a default.

solid answer

~50 s

There is no invalidation API: the cache is just an entry in `instance.__dict__`, so you remove it the way you remove any instance attribute. `del obj.attr` deletes the entry and the next read runs the function again. Because `cached_property` defines no `__delete__`, the deletion is handled by the ordinary attribute machinery, which raises `AttributeError` if the value was never computed — so unconditional invalidation is safer written as `obj.__dict__.pop("attr", None)`, or wrapped in `try`/`except AttributeError`. Assignment works the same way in reverse: `obj.attr = value` writes straight into the dict and pre-seeds or overwrites the cache, since a non-data descriptor does not intercept writes. The design point worth stating is that **`cached_property` gives you no staleness detection at all** — if the value derives from state that mutates, invalidation is a contract you own and must enforce at every mutation site.

code

python · 24 lines
python
from functools import cached_property

class Transcript:
    def __init__(self, lines):
        self.lines = lines

    @cached_property
    def line_count(self):
        return len(self.lines)

t = Transcript(["a", "b"])
print(t.line_count)      # 2
t.lines.append("c")
print(t.line_count)      # 2 - stale, nothing watches the input
del t.line_count
print(t.line_count)      # 3 - recomputed
t.line_count = 99        # plain instance-dict write, no __set__ involved
print(t.line_count)      # 99

t.__dict__.pop("line_count", None)
try:
    del t.line_count     # nothing cached now
except AttributeError as exc:
    print("AttributeError:", exc)

go deeper

for a junior

Know the gesture and the reason: del obj.attr drops the remembered value because it is stored as an ordinary attribute on that object, and the next read computes it again.

for a middle

Explain why del raises before the first read, why assignment silently pre-seeds the cache, and why obj.__dict__.pop(name, None) is the safe unconditional form.

for a senior

Demonstrate judgement about staleness: which derived values are safe to freeze for an object's lifetime, and how you keep invalidation next to the mutation instead of scattered across the class.

for a principal

Own the alternative: pushing toward immutable value objects or narrow mutation surfaces so that cache invalidation stops being a per-call-site obligation the team can forget.

## The cache is an ordinary attribute, so ordinary attribute operations manage it Once you know `cached_property` stores its result in `instance.__dict__` under its own name, invalidation stops being a special topic. The cached value is indistinguishable from an attribute you assigned yourself. Three gestures follow: - **`del obj.attr`** removes the dict entry. The next read misses the dict, falls through to the descriptor, and recomputes. - **`obj.attr = value`** writes the dict entry directly. The descriptor never sees it, because with no `__set__` defined it does not participate in assignment. This both pre-seeds a cache before any read and overwrites one after. - **`"attr" in obj.__dict__`** tells you whether the value has been computed yet, without triggering the computation the way reading the attribute would. The one sharp edge is that `del` on an uncomputed attribute raises `AttributeError`. `cached_property` defines no `__delete__`, so the deletion goes through the normal path, which finds no instance-dict entry and reports the attribute as missing — even though the class clearly has one. For code that must invalidate unconditionally, `obj.__dict__.pop("attr", None)` is the idiomatic form; a `try`/`except AttributeError` is equally correct and more explicit about intent. ```python t = Transcript(["a", "b"]) t.line_count # 2, computed and cached t.lines.append("c") t.line_count # still 2 - stale del t.line_count # drop the cached entry t.line_count # 3, recomputed ``` ## Naming the entry to delete Invalidation code that hard-codes the string name is fragile against renames, and worse, silently fragile: `obj.__dict__.pop("lien_count", None)` never raises, it just never invalidates anything. Two habits help. Keep the invalidation next to the mutation that causes it, in a single method, so the pair is reviewed together. And prefer a small explicit helper that lists the derived names once: ```python _DERIVED = ("line_count", "total_bytes") def _invalidate(self): for name in _DERIVED: self.__dict__.pop(name, None) ``` A more aggressive variant walks the class looking for `cached_property` instances and pops each name, which is rename-proof but also invalidates values whose inputs did not change. That is fine for a coarse "the object was rewritten" event and wrong as a routine gesture. ## The design question underneath Manual invalidation is a maintenance burden that grows with every mutation site, and each missed site is a wrong-answer bug rather than a crash — the value is plausible, just old. So the honest reading of "how do I invalidate?" is often "should this be a `cached_property` at all?" Three shapes, in decreasing order of how well they suit the decorator: 1. **Derived from state fixed for the object's lifetime.** A parse of an immutable payload, a total over a tuple, a compiled form of a constant. Nothing can go stale, so no invalidation exists to forget. This is what `cached_property` is for. 2. **Derived from state that changes at a small number of well-known points.** Invalidate explicitly at those points, keep them few, and keep the derived-name list in one place. Workable, and the burden is proportional to how disciplined the mutation surface is. 3. **Derived from state that changes anywhere.** Do not cache on the instance. Either recompute with a plain `property`, or restructure so that a mutation produces a new object rather than editing one in place — an immutable value object makes the whole question disappear, because a changed input means a different instance and therefore a cold cache by construction. A concrete version of shape 3: a chat-transcript archiver whose record object caches a derived per-record summary, and whose partial-failure rollback path mutates several records back to an earlier state. Every rollback must now remember to invalidate every derived value on every touched record, and the rollback path is by definition the least-exercised code you have. Rebuilding the affected records from their pre-change state is both simpler and self-invalidating. ## Two things not to confuse it with Deleting the attribute does not remove or disable the descriptor — that lives on the class and continues to serve every instance, including this one on its next read. And invalidating on one instance affects only that instance; there is no shared or class-wide cache to clear, which is a frequent misconception carried over from function-level caching decorators, where a single cache is attached to the function and holds entries for every caller. The interview answer, compressed: **`del obj.attr` (or a `pop` from `obj.__dict__` when it may be absent), because the cache is an instance-dictionary entry — and if you find yourself writing invalidation in several places, the value probably should not be cached on a mutable instance.**

  • Why does deleting an attribute that was never read raise AttributeError?
    `cached_property` defines no `__delete__`, so `del` goes through the ordinary attribute machinery, which looks for an entry in the instance `__dict__`. Before the first read there is none, so it reports the attribute as missing even though the class defines it. Use `obj.__dict__.pop(name, None)` when the deletion must be unconditional.
  • Does clearing the cached value on one object affect the other instances?
    No. Each instance has its own dictionary entry, so invalidation is strictly per object; other instances keep whatever they computed. There is no shared cache to clear — a common wrong instinct carried over from function-level caching decorators, where one cache lives on the function and holds entries for every caller.
  • You find invalidation calls scattered across five methods. What does that tell you?
    That the value derives from state mutating in too many places for a lifetime cache to be honest. Either narrow the mutation surface to one method that invalidates, drop to a plain `property` that recomputes, or restructure so a change produces a new object — an immutable value object is self-invalidating, because different state means a different instance with a cold cache.

saying these in an interview costs you the question

  • Looking for a clear() or invalidate() method on the descriptor
  • Believing the cache refreshes itself when inputs change
  • Assuming del is always safe before the first read
  • Thinking invalidation on one object clears every instance
  • Reassigning the class attribute to reset one object's value

context