Skip to content
← All articles

cached_property: the 3.12 lock removal and the __slots__ crash

Code written pre-3.12 that relied on the implicit once-only guarantee under threading is now racy. And a __slots__ class with a cached_property type-checks clean and raises at runtime.

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.