Skip to content
← All Python articles

Python explainer

Compute an Attribute Once with cached_property

About 3 min to read. Allow extra time to work through examples.

Compute an attribute on its first read, clear it when its inputs change, and cache by hand in a class that uses slots.

This article uses Python. You can make it your practice mode to browse related content across the site.

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 = 100 replaces it, and reads return 100 until you invalidate. A plain @property without a setter raises AttributeError instead.
  • If the method raises, cached_property stores 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

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.

step 28 of 55

Everyday Standard Library Tools

Up next Cache a Report Total Until You Clear It