functools.cached_property Lost Its Thread-Safety Lock in Python 3.12 (tested)

Before Python 3.12, functools.cached_property used an undocumented lock so that if two threads accessed the same uncached property at once, the getter still only ran once. Python 3.12 removed that lock -- the official reason is that the lock was per-property rather than per-instance, so unrelated instances sharing a class could contend on the same lock under load. We measured what code written against the old guarantee actually does now.

1. Ten threads, ten executions of a "cached" property

import functools, threading, time

calls = []

class Report:
    @functools.cached_property
    def summary(self):
        calls.append(1)
        time.sleep(0.2)
        return "computed"

r = Report()
threads = [threading.Thread(target=lambda: r.summary) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("getter ran", len(calls), "times")
getter ran 10 times

Measured: all 10 threads read instance.__dict__, see the attribute isn't cached yet, and all 10 start running the getter before any of them finishes and writes the cache -- on Python 3.13.5's cached_property source there is no lock of any kind around that check. The time.sleep(0.2) just widens the window; the race exists regardless of timing, it's only easier to observe with a slow getter.

2. The cached value ends up being whichever thread finished last

import functools, threading, time

class Counter:
    def __init__(self):
        self.n = 0
    @functools.cached_property
    def value(self):
        self.n += 1
        time.sleep(0.1)
        return self.n

c = Counter()
threads = [threading.Thread(target=lambda: c.value) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print("final cached value:", c.__dict__["value"], "but getter ran", c.n, "times")
final cached value: 5 but getter ran 5 times

Measured: the getter body ran 5 times (once per thread), each writing its own result into instance.__dict__["value"]; whichever thread's write lands last wins, silently discarding the other 4 computations' results. There's no error -- just a cached value that reflects the last writer, not necessarily the first or "correct" one if the getter isn't idempotent.

3. The fix: lock it yourself if you need the old guarantee

import functools, threading, time

class Report:
    def __init__(self):
        self._lock = threading.Lock()
    @functools.cached_property
    def summary(self):
        with self._lock:
            if "summary" in self.__dict__:
                return self.__dict__["summary"]
            time.sleep(0.2)
            return "computed"

r = Report()
calls = []
def access():
    calls.append(r.summary)
threads = [threading.Thread(target=access) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print("all results identical:", len(set(calls)) == 1)

Measured: wrapping the getter body in an explicit per-instance threading.Lock (and re-checking the cache inside the lock, since cached_property doesn't do that for you) restores run-once-per-instance behavior under concurrency. On 3.11 and earlier this extra lock is redundant but harmless; on 3.12+ it's the only way to get the old guarantee back.

Frequently asked questions

Did Python 3.12 remove thread-safety from functools.cached_property?

Yes. Prior to 3.12, cached_property used an internal lock so the getter ran at most once per instance even under concurrent access. We measured on Python 3.13.5 that this is gone: 10 threads hitting the same uncached property concurrently ran the getter 10 times, not once.

Why did Python remove the lock instead of fixing it?

The documented reason is that the old lock was per-property, not per-instance -- so unrelated instances of the same class competing for the same lock could cause unacceptably high contention under load. Removing it fixed that performance problem but removed the once-per-instance guarantee as a side effect.

Is this a crash or just wasted work?

Just wasted work and a silent correctness risk, not a crash. We measured the getter running once per racing thread with no exception; the final cached value is whichever thread's write happens to land last, which only matters if the getter isn't idempotent or has side effects.

How do I get the old once-per-instance guarantee back on Python 3.12+?

Add your own per-instance threading.Lock around the getter body, re-checking instance.__dict__ for the cached value inside the lock before doing the expensive work -- we measured this restoring run-once behavior under concurrent access.

Written with AI assistance. Measured on 2026-10-08 with Python 3.13.5's built-in functools.cached_property: ran 10 threads calling the same cached_property on one shared instance simultaneously, where the getter sleeps 0.2s and appends to a list before returning, then counted how many times the getter actually ran. PlainHeaders is our extension; Python's functools module is built in.