skip to content

How does functools.cached_property make every read after the first skip the descriptor?

level: middleimportance: must knowfreq 48%

answer

  1. Where does the cached value live?
  2. Which wins: instance dict or this descriptor?
  3. It omits __set__ and __delete__ on purpose
  4. Non-data descriptor, shadowed after first read
  5. __set_name__ captures the cache key

basics

~20 s

It is a non-data descriptor: the first read runs the function and stores the result in the instance __dict__ under the same attribute name. Later reads find that entry first, so the descriptor never runs again.

solid answer

~40 s

`functools.cached_property` defines only `__get__` and `__set_name__` — no `__set__`, no `__delete__` — which makes it a **non-data descriptor**. Attribute lookup on an instance checks data descriptors on the class first, then the instance `__dict__`, then non-data descriptors. On the first read the instance dict has no entry, so lookup falls through to the descriptor, which calls the wrapped function and writes the result into `instance.__dict__[name]` — the same name the descriptor is bound to, captured by `__set_name__` at class creation. Every later read finds that dict entry in the earlier step and returns it directly, so there is no call, no lookup on the class, and no per-read overhead. That is the whole difference from `property`, which is a data descriptor and therefore runs its getter on every single access.

code

pycon · 17 lines
pycon
>>> from functools import cached_property
>>> class Archive:
...     def __init__(self, messages):
...         self.messages = messages
...     @cached_property
...     def total_bytes(self):
...         print("computing")
...         return sum(len(m) for m in self.messages)
...
>>> a = Archive(["hi", "there"])
>>> a.total_bytes
computing
7
>>> a.total_bytes
7
>>> a.__dict__
{'messages': ['hi', 'there'], 'total_bytes': 7}

go deeper

for a junior

Be ready to say what it does in one line: the first read computes and remembers the value on that object, later reads reuse it. Know that it is per instance, not shared across objects.

for a middle

Explain the mechanism, not just the effect: only __get__ is defined, so it is a non-data descriptor that loses to the instance __dict__, and it stores its result there under the name __set_name__ captured.

for a senior

Show that you treat it as a memory and staleness decision. Say when the derived value is safe to freeze for the object's lifetime, and what holding it pins in memory on long-lived objects.

for a principal

Own the guidance: where in a codebase lazy caching is a legitimate pattern versus where it hides an expensive call that should be explicit, and how you keep teams from caching values whose inputs mutate.

## What the decorator actually builds `@cached_property` replaces the decorated function with an instance of the `functools.cached_property` class stored as a **class attribute**. That object holds the original function and, once the class body finishes, the attribute name it was bound to — Python calls `__set_name__(owner, name)` on every class-body value at class-creation time, and `cached_property` uses that hook to learn the name it must cache under. This matters: if you attach one after the fact (`C.x = cached_property(f)`), `__set_name__` never fires and the first read raises `TypeError: Cannot use cached_property instance without calling __set_name__ on it.` Binding the same object to two names in one class body is also rejected, because one descriptor can only own one cache key. ## Why the second read is free Instance attribute lookup goes through `__getattribute__`, which resolves in a fixed order: 1. Walk the type's MRO for the name. If what it finds is a **data descriptor** — an object defining `__set__` or `__delete__` — call its `__get__` and stop. 2. Otherwise look in the instance `__dict__`. If the name is there, return that value and stop. 3. Otherwise fall back to the class attribute found in step 1; if it is a **non-data descriptor**, call its `__get__`. `cached_property` deliberately implements only `__get__` (plus `__set_name__`), so it lands in step 3 and loses to the instance dict. First access: the dict is empty for that name, step 2 misses, step 3 calls `__get__`, which computes `func(instance)` and assigns it into `instance.__dict__`. Second access: step 2 hits, and the descriptor is not consulted at all. The cached read costs one ordinary dict lookup — the same as reading `self.messages` — with no function call and no descriptor machinery. ```python from functools import cached_property class Archive: def __init__(self, messages): self.messages = messages @cached_property def total_bytes(self): print("computing") return sum(len(m) for m in self.messages) a = Archive(["hi", "there"]) a.total_bytes # prints "computing", returns 7 a.total_bytes # returns 7, prints nothing a.__dict__ # {'messages': [...], 'total_bytes': 7} ``` Note that `Archive.total_bytes` — access on the class, with no instance — returns the descriptor object itself, because `__get__` short-circuits when `instance is None`. That is how the decorator stays introspectable and how `help()` still sees the docstring, which `cached_property` copies from the wrapped function. ## The contract you take on **The cache is per instance and lives exactly as long as the instance.** There is no size bound, no expiry and no key: one value, stored beside the object's ordinary attributes. Two consequences follow. First, **staleness is yours to manage.** Nothing watches the inputs. If `total_bytes` is derived from a list you keep appending to, the cached number silently stops matching reality. The rule of thumb is to reach for `cached_property` only when the derived value is a function of state that does not change for the object's lifetime — a parsed header, a compiled pattern, a total over an immutable payload. When the inputs do change, you must invalidate explicitly. Second, **the cached value is retained as long as the instance is**, along with everything it references. Caching a large derived structure on a long-lived object is a memory decision, not just a speed one: profiling a service that holds thousands of such objects will show the derived data, not the source data, as the growth. ## Where it sits next to the alternatives `property` recomputes on every read and can validate on write; it is the right choice when the value must track changing state. Computing eagerly in `__init__` is simpler and cheaper to reason about when the value is always needed and cheap; `cached_property` earns its place when the computation is expensive *and* often not needed at all, so paying for it lazily on the objects that ask is the win. And because the descriptor loses to the instance dict, a plain assignment `obj.attr = value` before the first read simply pre-seeds the cache — which is convenient for tests and for injecting a precomputed value, and impossible with a read-only `property`. The one thing to say out loud in an interview is the mechanism, not the effect: *it is a non-data descriptor that writes into the instance dictionary under its own name, so subsequent lookups shadow it.* Every other property of `cached_property` — the `__slots__` restriction, `del` as the invalidation gesture, the thread-safety caveat — falls straight out of that sentence.

  • What happens if you assign to the attribute before ever reading it?
    The assignment goes straight into the instance `__dict__`, because a non-data descriptor does not intercept writes. The wrapped function then never runs for that instance — the cache is simply pre-seeded. That is a real convenience for tests and for injecting a precomputed value, and it is impossible with a read-only `property`, which raises `AttributeError` on assignment.
  • Why does accessing the attribute on the class rather than an instance not compute anything?
    `__get__` receives `instance=None` for a class-level access and returns the descriptor object itself. There is no instance dictionary to cache into and no `self` to pass to the wrapped function, so returning the descriptor is the only sensible answer — and it keeps the attribute introspectable, which is why `help()` and documentation tools still see the original docstring.
  • When would you prefer computing the value eagerly in __init__ instead?
    When the value is always needed, or cheap, or when construction is the natural place to fail. Eager computation makes cost and errors visible at a predictable point and keeps the object's state obvious. `cached_property` pays off when the computation is expensive *and* frequently skipped, so you only spend it on the objects that actually ask for the value.

It is a signpost that, the first time someone follows it, gets replaced by the destination itself standing in the road — nobody after that reads the sign.

saying these in an interview costs you the question

  • Calling it a data descriptor
  • Saying the value is cached on the class, shared by all instances
  • Claiming it recomputes when the underlying attributes change
  • Confusing it with a per-arguments function cache keyed on inputs
  • Believing the descriptor runs on every attribute read
  • Assuming the cache is bounded or expires on its own

context