functools.cached_property computes a value on first access and stores it in
the instance’s __dict__ under the property’s own name. Every later
access finds the plain attribute and the descriptor is never consulted again —
which is why it is faster than a @property with a manual cache, and why the
cached value dies with the instance rather than leaking on the class.
class Report:
def __init__(self, rows: list[int]) -> None:
self.rows = rows
@cached_property
def total(self) -> int:
return sum(self.rows)
Two things about it will bite you.
The lock was removed in 3.12
Before 3.12, cached_property held a lock while computing, so under threads
the function ran exactly once. That lock was per property, not per
instance, so every thread touching any instance of the class serialised on
it — a real contention problem in a threaded server, for a guarantee most
callers did not need.
It was removed in 3.12. The behaviour now: multiple threads may compute the value concurrently, and the last one to write wins. All of them see a valid value, so the result is correct for an idempotent computation and wrong for anything with side effects, anything expensive you were relying on running once, or anything that must return the same object to all callers.
If you need the once-only guarantee, write it yourself with a per-instance lock. If you were relying on it without knowing, upgrading to 3.12 changed your program’s semantics with no deprecation warning.
__slots__ makes it crash
A __slots__ class has no __dict__, and cached_property has nowhere to
store the result:
class Point:
__slots__ = ()
@cached_property
def norm(self) -> float:
return 0.0
Point().norm
# TypeError: No '__dict__' attribute on 'Point' instance to cache 'norm' property.
That code passes mypy --strict cleanly. It is probably the single best
one-line demonstration that --strict is necessary but not sufficient:
the checker verifies the types of a program that cannot run.
💡__slots__ and cached_property are individually good ideas —
click to reveal
one saves memory, the other saves computation. What do you do when you want
both?
Add "__dict__" to the slots (__slots__ = ("x", "y", "__dict__")), which
restores the dict and therefore most of the memory you were saving — usually
the wrong trade, since the point of slots was the memory.
Better: keep the slots and store the cache in a declared slot yourself.
class Point:
__slots__ = ("x", "y", "_norm")
@property
def norm(self) -> float:
if self._norm is None:
self._norm = math.hypot(self.x, self.y)
return self._norm
You lose the one-line decorator and gain an explicit, slotted, still-cheap
cache. Note that __slots__ is worth about 30% memory on modern CPython —
not the 5-10x sometimes claimed — and does not speed up attribute access
(measured 3.9 ns against 3.8 ns). So if the derived value is expensive, the
cache is worth more than the slots and the honest answer may be to drop
__slots__.
Invalidation
There is no invalidate() method. The documented way to clear the cache is to
delete the attribute:
del report.total
which raises AttributeError if the value was never computed — so a general
invalidate() needs the pop form:
def invalidate(self) -> None:
self.__dict__.pop("total", None)
That is the whole implementation. Reaching into __dict__ looks like a
violation and is in fact the supported mechanism — the storage location is
documented, and it is what the descriptor itself writes to.
💡cached_property reads a value from the instance dict on every
click to reveal
access after the first. What does that imply about a subclass that wants to
override it with a plain attribute?
It works, and that is worth knowing because it is unusual. cached_property
is a non-data descriptor — it defines __get__ but not __set__ — and
non-data descriptors lose to the instance dict. So setting obj.total = 5
directly just works and permanently shadows the property for that instance.
A regular @property is a data descriptor and wins against the instance
dict, so obj.total = 5 on one of those raises AttributeError: can't set attribute.
The practical use is in tests: you can stub a cached_property on an instance
with a plain assignment and no patching machinery. The practical hazard is
that a typo’d assignment silently disables the computation instead of raising.