skip to content

How does functools.cached_property differ from a plain property?

level: middleimportance: should knowfreq 45%

answer

  1. One of the two is not a data descriptor
  2. The instance dictionary wins the lookup
  3. Computed once, lives and dies with the object
  4. Needs somewhere on the instance to store it
  5. del the attribute to force a recompute

basics

~20 s

A plain property runs its getter on every attribute access. functools.cached_property runs the getter once per instance, stores the value in that instance's dict under the same name, and every later access finds the stored value instead.

solid answer

~50 s

`property` is a data descriptor: it intercepts every read of the attribute and runs the getter each time. `functools.cached_property` is a *non-data* descriptor, which means instance-dictionary entries win over it. The first access runs the function and writes the result into the instance's `__dict__` under the attribute name; from then on normal attribute lookup finds that entry and the descriptor is never consulted again. So the value is computed once per instance and dies with the instance — no shared cache, no eviction policy, nothing to size. The cost is that it needs a mutable `__dict__`, so a class using `__slots__` raises `TypeError`, and there is no automatic invalidation: if the inputs change you must `del obj.attr`, which removes the stored entry so the next access recomputes. It also cannot be given a setter or a deleter the way `property` can.

code

python · 17 lines
python
import functools

class Report:
    def __init__(self, rows):
        self.rows = rows

    @functools.cached_property
    def total(self):
        print("computing total")
        return sum(self.rows)

r = Report([1, 2, 3])
print(r.total)
print(r.total)
print(r.__dict__)
del r.total
print(r.total)

go deeper

for a junior

Know that it turns an expensive derived attribute into something computed on first access and cheap afterwards, and that the result is remembered on that one object rather than shared between objects.

for a middle

Explain the storage mechanism — the result goes into the instance's __dict__ under the same name, and later lookups find it before the descriptor. From that, derive why __slots__ breaks it and why del obj.attr is the way to invalidate.

for a senior

Discuss lifetime and staleness: the value lives exactly as long as the instance, so it cannot leak, but nothing invalidates it when inputs change. Know that the internal lock was removed in 3.12 and what that means for a first access under concurrency.

for a principal

Frame it as an API-design choice: making an attribute lazily cached commits every future caller to treating the object as immutable after first read. Decide when eager computation in the constructor, or an explicit method, is the more honest contract.

## Two descriptors, one difference that explains everything Both are descriptors — objects that live on the *class* and customise what happens when you read an attribute on an *instance*. The difference is which kind. `property` defines `__get__` **and** `__set__`, making it a **data descriptor**. Data descriptors take priority over the instance dictionary, so a property's getter runs on every single read no matter what is in `__dict__`. `functools.cached_property` defines only `__get__`, making it a **non-data descriptor**, and non-data descriptors lose to the instance dictionary. That single fact is the whole mechanism: on the first read the descriptor runs, computes the value, and stores it in `obj.__dict__` under its own name — which it learned via `__set_name__` when the class body was created. On the second read, ordinary attribute lookup finds the instance-dictionary entry before it ever reaches the class, so the descriptor is bypassed entirely. Subsequent reads cost exactly what a normal attribute costs. ```python import functools class Batch: def __init__(self, rows): self.rows = rows @functools.cached_property def summary(self): return sum(self.rows), len(self.rows) ``` ## What the scoping buys you The cache is *per instance*. There is no shared dictionary, no `maxsize` to choose, and no way for the cache to outlive the object: when the instance is collected, its `__dict__` goes with it, and so does the cached value. That is the sharp contrast with putting `functools.lru_cache` on a method, where a single class-level cache holds `self` in its key and therefore keeps instances alive. It is the right tool when an attribute is derived from data the instance already holds, is expensive enough to be worth not recomputing, and does not change for the lifetime of the object — a parsed form of a raw field, an aggregate over a fixed collection, a lazily-opened resource handle. ## The three ways it bites **It needs a mutable `__dict__`.** A class with `__slots__` and no `__dict__` raises `TypeError` on first access, saying there is no `__dict__` on the instance to cache the property into. The same is true for classes whose instances proxy attribute storage elsewhere. This collides directly with the other common memory optimisation: `__slots__` and `cached_property` do not compose. **There is no invalidation.** The value is frozen at first read. If the attribute is derived from something mutable — a list the caller keeps appending to, a field that gets reassigned — later reads return a stale answer, and nothing warns you. The manual escape hatch is `del obj.attr`, which deletes the instance-dictionary entry so the next access recomputes; note that this raises `AttributeError` if the value was never computed, so guard it or use `obj.__dict__.pop(name, None)`. **It is not atomic under concurrency.** Up to Python 3.11 `cached_property` held an internal lock, which serialised *all* instances of the class through one mutex and was a genuine scalability problem. That lock was removed in **3.12**. The consequence is the opposite risk: two threads reading the attribute at the same time on the same fresh instance can both run the function, and one result silently overwrites the other. For a pure computation that is merely wasted work; if the function opens a socket, spawns something, or otherwise has a side effect, you need your own lock instead. ## Distinguishing it in an interview A candidate who says "it caches the property" has said nothing yet. The answers that show real understanding are: it stores into the instance dictionary and relies on non-data-descriptor precedence, which is why `__slots__` breaks it and why `del` invalidates it; the cache lifetime is the object's lifetime, which is why it cannot leak the way a class-level memo can; and it offers no setter, so a value that must be both computed lazily and assigned needs a real `property` with a private backing field, or a plain attribute computed in `__init__` if eagerness is acceptable. The last of those is worth saying out loud: laziness is the actual feature. If computing the value in `__init__` is cheap and always needed, do that instead — it is simpler, it works with `__slots__`, and it has no concurrency question at all.

  • How do you invalidate a cached_property value?
    Delete the attribute — `del obj.attr` — which removes the entry the descriptor wrote into the instance dictionary, so the next read recomputes. It raises `AttributeError` if the value was never computed, so `obj.__dict__.pop('attr', None)` is the safe form inside library code. There is no hook that fires when the underlying data changes; invalidation is entirely manual.
  • Why does a class using __slots__ fail with cached_property?
    `__slots__` removes the per-instance `__dict__`, and the descriptor's whole mechanism is writing its result into that dictionary. On first access it raises `TypeError` reporting there is no `__dict__` on the instance to cache into. Either add `__dict__` to the slots tuple — which gives back most of the memory `__slots__` was saving — or compute the value eagerly into a real slot.
  • Two threads read the same fresh instance's cached_property at once. What happens?
    On 3.12 and later both can run the underlying function, and whichever finishes last wins the instance-dictionary entry; the internal lock that previously prevented this was removed because it serialised every instance of the class. For a pure computation this is duplicated work and nothing more. If the function has side effects or is very expensive, take your own lock rather than relying on the descriptor.

A property is a clerk who recalculates your balance every time you ask; cached_property is a clerk who writes the answer on a sticky note stuck to your own file, and never looks past the note again until someone tears it off.

saying these in an interview costs you the question

  • Says it recomputes when the underlying data changes
  • Thinks the cache is shared across all instances
  • Cannot explain why __slots__ breaks it
  • Believes you can attach a setter to it
  • Claims first access is atomic across threads
  • Confuses it with putting lru_cache on a method

context