copy.deepcopy Copies a functools.cached_property's Stale Cached Value (tested)

functools.cached_property stores its computed result as an ordinary entry in instance.__dict__ once accessed -- there's nothing marking it as "derived" or cache-like to copy.copy/copy.deepcopy. Both copy functions operate on __dict__ generically, so a cached_property's stored result is copied exactly like any other attribute: as a frozen value, not as something to recompute. If the underlying data the property depends on changes before the copy is made, the copy inherits the old answer, not a fresh one.

1. Deep-copying after a mutation keeps the pre-mutation cached result

import functools, copy

class Config:
    def __init__(self, base):
        self.base = base
    @functools.cached_property
    def derived(self):
        return self.base * 2

c = Config(5)
print("derived:", c.derived)   # computes and caches 10
c.base = 100                    # mutate AFTER caching

d = copy.deepcopy(c)
print("deepcopy's base:", d.base)
print("deepcopy's derived:", d.derived)
print(d.__dict__)
derived: 10
deepcopy's base: 100
deepcopy's derived: 10
{'base': 100, 'derived': 10}

Measured: the deep copy's base is correctly 100 -- deepcopy did its job on that plain attribute. But derived comes out as 10, the value cached before the mutation, not 200 (what self.base * 2 would actually produce now). cached_property's result sat in __dict__ as {'derived': 10}, and deepcopy copied that entry exactly like base -- it has no way to know derived was ever a computed, potentially-stale value rather than real data.

2. copy.copy (shallow) has the identical effect

import functools, copy

class Config:
    def __init__(self, base):
        self.base = base
    @functools.cached_property
    def derived(self):
        return self.base * 2

c = Config(5)
c.derived           # cache it: 10
c.base = 100
s = copy.copy(c)
print("shallow copy base:", s.base, "derived:", s.derived)
shallow copy base: 100 derived: 10

Measured: same inconsistency with copy.copy -- it isn't specific to the deep-copy traversal logic, it's simply that both copy functions treat __dict__ contents uniformly, with no special case for an entry that happened to originate from a cached_property getter.

3. The fix: clear the cached entry before or after copying

import functools, copy

class Config:
    def __init__(self, base):
        self.base = base
    @functools.cached_property
    def derived(self):
        return self.base * 2

c = Config(5)
c.derived
c.base = 100
d = copy.deepcopy(c)
del d.__dict__["derived"]   # drop the stale cache entry
print("derived after invalidation:", d.derived)
derived after invalidation: 200

Measured: deleting the cached entry from the copy's __dict__ (the same technique used to invalidate a cached_property on the original object) forces the next access to recompute from the copy's own, current base -- giving the correct 200. There's no built-in copy hook for this; it has to be done explicitly after every copy, or by implementing __deepcopy__/__copy__ to strip cached entries automatically.

Frequently asked questions

Does copy.deepcopy recompute a functools.cached_property for the new object?

No. We measured that deepcopy copies whatever is already in instance.__dict__, including any already-computed cached_property result, as plain data. If the underlying attributes the property depends on were mutated before the copy, the copy keeps the old cached answer rather than recomputing it.

Does copy.copy (shallow copy) have the same issue?

Yes, identically -- we measured the same stale cached_property value surviving a copy.copy() in addition to copy.deepcopy(). Both operate on __dict__ the same generic way.

How do I force a copied object to recompute its cached_property?

Delete the cached entry from the copy's __dict__ (e.g. del obj.__dict__['propname']) right after copying -- we measured this forcing a correct recomputation on next access, using the copy's own current attribute values.

Is this specific to cached_property, or does it affect ordinary @property too?

Ordinary @property has no __dict__ entry at all -- it recomputes every access from the live object, including on a copy, so it's unaffected. The issue is specific to cached_property (and any other pattern that memoizes a value directly into instance.__dict__).

Written with AI assistance. Measured on 2026-10-08 with Python 3.13.5's built-in copy.deepcopy/copy.copy and functools.cached_property: accessed a cached_property to populate its cache, mutated the instance's underlying attribute afterward, then copied the instance and checked whether the copy's cached_property reflects the copy's own (mutated, deep-copied) attribute value or the original cached result. PlainHeaders is our extension; Python's functools module is built in.