functools.cached_property Writes to a Frozen Dataclass's __dict__ Anyway (tested)
@dataclass(frozen=True) is the standard way to make a Python object's state immutable: assigning to any declared field raises FrozenInstanceError. We measured that a functools.cached_property method on that same frozen instance writes a brand-new entry into the instance's __dict__ on first access, with no error at all -- because it bypasses the overridden __setattr__ that frozen dataclasses rely on to block mutation.
1. Frozen blocks a normal field assignment, not a cached_property access
import dataclasses, functools
@dataclasses.dataclass(frozen=True)
class Point:
x: float
y: float
@functools.cached_property
def magnitude(self):
print("computing magnitude")
return (self.x**2 + self.y**2) ** 0.5
p = Point(3, 4)
try:
p.x = 10
except Exception as e:
print("setting x RAISED", type(e).__name__, e)
print("magnitude:", p.magnitude)
print("magnitude again:", p.magnitude)
print(p.__dict__)
setting x RAISED FrozenInstanceError cannot assign to field 'x'
computing magnitude
magnitude: 5.0
magnitude again: 5.0
{'x': 3, 'y': 4, 'magnitude': 5.0}
Measured: p.x = 10 raises immediately, exactly as frozen=True promises. But the very next line, p.magnitude, silently adds a 'magnitude' key to p.__dict__ -- a dict that, going by the FrozenInstanceError above, looked completely locked down one line earlier. The second access is a cache hit (no second "computing" print), confirming the write actually happened and stuck.
2. Why: cached_property writes to the dict directly, not through setattr
import dataclasses, functools
@dataclasses.dataclass(frozen=True)
class Point:
x: float
y: float
@functools.cached_property
def magnitude(self):
return (self.x**2 + self.y**2) ** 0.5
p = Point(3, 4)
print("before:", p.__dict__)
p.magnitude
print("after:", p.__dict__)
before: {'x': 3, 'y': 4}
after: {'x': 3, 'y': 4, 'magnitude': 5.0}
Measured: a frozen dataclass's immutability comes entirely from a generated __setattr__ that raises on every call. functools.cached_property.__get__ does not call setattr(instance, name, value) -- the cached_property docs themselves note it stores the result straight into the instance's __dict__ for performance, skipping the attribute-assignment machinery entirely. That implementation choice is exactly what lets it slip past a guard that every ordinary assignment respects.
3. The hash and equality stay safe -- it's specifically the "immutable" guarantee that doesn't
import dataclasses, functools
@dataclasses.dataclass(frozen=True)
class Point:
x: float
y: float
@functools.cached_property
def magnitude(self):
return (self.x**2 + self.y**2) ** 0.5
p1 = Point(3, 4)
p2 = Point(3, 4)
s = {p1}
print("p1 == p2:", p1 == p2)
print("hash(p1) == hash(p2):", hash(p1) == hash(p2))
p1.magnitude # triggers the hidden __dict__ write
print("p1 in s after accessing magnitude:", p1 in s)
print("hash(p1) == hash(p2) still:", hash(p1) == hash(p2))
p1 == p2: True
hash(p1) == hash(p2): True
p1 in s after accessing magnitude: True
hash(p1) == hash(p2) still: True
Measured: a frozen dataclass's generated __eq__/__hash__ only look at the declared fields (x, y), so the cached_property's extra __dict__ entry doesn't break hashing or set membership here. What it does break is the broader assumption that "frozen means nothing about this object's memory ever changes again" -- code that relies on that for thread-safety, deep-copy snapshotting, or memory-identity comparisons across time is measurably wrong the first time a cached_property is touched.
Frequently asked questions
Does functools.cached_property work on a frozen dataclass?
Yes, and that's the surprise -- we measured it writing a new key into the frozen instance's __dict__ on first access with no error, even though assigning to any declared field on the same instance raises dataclasses.FrozenInstanceError immediately.
Why doesn't frozen=True stop cached_property from writing to the instance?
frozen=True works by overriding __setattr__ to raise on every call. functools.cached_property.__get__ stores its result by writing directly into the instance's __dict__, which is documented as an optimization, and that bypasses __setattr__ entirely -- the guard a frozen dataclass relies on is simply never invoked.
Does this break hashing or equality for a frozen dataclass with a cached_property?
No, we measured hash() and == staying consistent before and after the cached_property was accessed, because the generated __eq__/__hash__ only consider the dataclass's declared fields, not arbitrary __dict__ entries. What breaks is the assumption that the object's memory never changes after construction.
Is there a way to get a cached, lazy attribute on a frozen dataclass without this gap?
Use object.__setattr__(self, name, value) explicitly inside a plain @property's getter if you want the bypass to be visible and intentional in your own code, or accept that functools.cached_property already does this silently and document it where the class is used.
Written with AI assistance. Measured on 2026-10-08 with Python 3.13.5's built-in functools and dataclasses modules: created a @dataclass(frozen=True) class with a functools.cached_property method, confirmed assigning to a declared field raises FrozenInstanceError, then accessed the cached_property and inspected the instance's __dict__ before and after. PlainHeaders is our extension; Python's functools and dataclasses modules are built in.