A Report adds up thousands of rows, and a dashboard reads its total many
times. The functools.cached_property decorator computes the total on the
first read and reuses it after that:
from functools import cached_property
class Report:
def __init__(self, rows: list[int]) -> None:
self.rows = list(rows)
self.computations = 0
@cached_property
def total(self) -> int:
self.computations += 1
return sum(self.rows)
def invalidate(self) -> None:
self.__dict__.pop("total", None)
report = Report([1, 2, 3])
print(report.total, report.total, report.computations) # 6 6 1
On the first read, cached_property runs the method and stores the result in
the instance's __dict__, the dictionary that holds an object's attributes.
Later reads find total there and never call the method.
Unlike lru_cache on a method, from the previous article, this stores the
value on the instance. The class keeps no reference to the instance, so Python
can free the instance as usual.
Clear the value when its inputs change
Nothing runs the method again when self.rows changes. After you change the
rows, reads still return the old total:
report.rows.append(5)
print(report.total) # 6, out of date
report.invalidate()
print(report.total, report.computations) # 11 2
Invalidating a cached value means throwing it away, so that the next read
computes it again. The invalidate method does this by removing total from
__dict__. Using pop with a default never raises. A plain del report.total
also works, but it raises AttributeError if no read has computed the total
yet.
If the total must always be up to date and summing is cheap, use a plain
@property, which runs on every read.
What gets stored
- The cached value is an ordinary attribute on the instance. So
report.total = 100replaces it, and reads return 100 until you invalidate. A plain@propertywithout a setter raisesAttributeErrorinstead. - If the method raises,
cached_propertystores nothing, and the next read runs the method again. - If the method returns a list, every read returns that same list. A change made through one read shows up in the next.
Classes with slots need another way
A class that sets __slots__ lists the attributes its instances can have, and
Python stores them without a per-instance __dict__. With no __dict__,
cached_property has nowhere to put the value, and reading the property raises
TypeError.
In such a class, keep the cached value in a slot of your own, behind a plain property:
import math
class Point:
__slots__ = ("x", "y", "_norm")
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
self._norm: float | None = None
@property
def norm(self) -> float:
if self._norm is None:
self._norm = math.hypot(self.x, self.y)
return self._norm
def invalidate(self) -> None:
self._norm = None
point = Point(3.0, 4.0)
print(point.norm) # 5.0
point.x = 0.0
point.invalidate()
print(point.norm) # 4.0
Here None works as the marker for "not computed yet", because a norm is
always a float. If None can be a real result, use a separate marker object.
Check your understanding
You add a row with report.rows.append(7) and read report.total. The total has not changed. Why, and what are two ways to fix it?
Show answer
Hide answer
The first read stored the total, and cached_property never looks at rows
again. One fix is to change the rows only through a method, such as add_row,
that appends and then invalidates. The other is a plain @property, which
recomputes on every read, if summing is cheap enough.
Going further
Before Python 3.12, cached_property held a lock so that only one thread at a
time could compute the value. Python 3.12 removed that lock. Now two threads
that read the property for the first time at the same moment can both run the
method. Each one can get its own result object. If that matters, put the
check, the computation and the store under one lock. The concurrency track
shows how.