Why can functools.cached_property run its function twice on Python 3.12 and later?
answer
- Something was taken out in 3.12
- The old guard was shared by all instances
- Caching is promised; at-most-once is not
- Two threads, two computations, last write wins
- Pure getters fine, resource-creating ones not
basics
~20 sPython 3.12 removed the lock that cached_property used to hold, so concurrent threads reading the attribute on a cold instance can each run the function. Both write to the instance dict and the last write wins.
solid answer
~50 sBefore 3.12, `functools.cached_property` guarded computation with a lock held on the **descriptor**, which is one object shared by every instance of the class — so threads touching completely unrelated instances serialized against each other, a well-documented bottleneck. Python 3.12 removed the lock outright rather than making it per instance. The documented behaviour since then is that the wrapped function **may run more than once** for the same instance under concurrency: two threads can both miss the instance-dict entry, both compute, and both assign, with the later assignment winning and any earlier result discarded. That is harmless when the function is a pure computation, and a real defect when it has side effects or allocates a resource — two threads each build one, one is silently orphaned. If you need at-most-once, hold your own lock or build the value eagerly; the free-threaded build, officially supported in 3.14, widens the window because the computations genuinely overlap.
code
python · 21 linesimport threading
from functools import cached_property
class Journal:
def __init__(self):
self.builds = 0
self.gate = threading.Barrier(2)
@cached_property
def handle(self):
self.gate.wait()
self.builds += 1
return object()
j = Journal()
workers = [threading.Thread(target=lambda: j.handle) for _ in range(2)]
for w in workers:
w.start()
for w in workers:
w.join()
print(j.builds) # 2 - the getter ran once per threadgo deeper
Take away one fact: cached_property guarantees that reads are cheap after the first, not that the function runs only once when several threads hit a cold attribute together.
Explain the sequence — both threads miss the instance dict, both compute, both assign, last write wins — and that 3.12 removed the lock that previously prevented it.
Show you can classify getters: pure and cheap is fine, side-effecting or resource-creating is not. Name a concrete remedy — eager construction or a per-instance double-checked lock — and why the old class-wide lock was worse.
Own the policy: which lazily-initialized values in a service are allowed to be recomputed, where at-most-once must be enforced explicitly, and what re-auditing the free-threaded build implies for code that leaned on the GIL.
## What 3.11 did, and why it was removed Through Python 3.11, `cached_property.__get__` ran a double-checked pattern around a lock: look in the instance dictionary, and on a miss acquire a lock, look again, compute, store, release. That lock was created in the descriptor's `__init__` — one lock per **class attribute**, not per instance. Every instance of the class contended for the same lock on that attribute. The consequence was a serialization bottleneck with no relationship to correctness. Suppose a chat-transcript archiver builds record objects with a `cached_property` doing a few milliseconds of parsing. Ten worker threads processing ten *different* records all queue behind one lock, and the class-wide throughput collapses to one computation at a time — even though no two threads share any state. Worse, if the wrapped function itself blocked on anything held by another thread already inside the same descriptor's getter, the shared lock turned a benign wait into a deadlock. Python 3.12 removed the lock rather than making it per instance, and the documentation now states plainly that the wrapped function may be run more than once on the same instance in a multi-threaded context. The reasoning is that a per-instance lock costs an extra object on every instance and still cannot make an arbitrary user function safe to run concurrently, whereas most cached values are pure computations where a duplicate run is merely wasted work. ## What the race actually looks like Two threads read a cold attribute. Both find no entry in the instance dictionary. Both call the function. Both assign into the dictionary. Afterwards: - The function has run **twice**, so any side effect happened twice. - The dictionary holds whichever value was assigned last. - Each thread's own read returns the value **it** computed, not necessarily the one now cached. So immediately after the race, two threads can be holding two different objects, while every later reader sees a third possibility — the stored one. That last point is the one people miss. If the value is a plain number, all three are equal and nothing is wrong. If the value is a resource — an open file, a connection, a journal handle, a lock object other code will synchronize on — then identity matters, and you have just distributed two different objects where the design assumed one. ```python import threading from functools import cached_property class Journal: def __init__(self): self.builds = 0 self.gate = threading.Barrier(2) @cached_property def handle(self): self.gate.wait() # hold both threads inside the getter self.builds += 1 return object() j = Journal() workers = [threading.Thread(target=lambda: j.handle) for _ in range(2)] for w in workers: w.start() for w in workers: w.join() print(j.builds) # 2 on 3.12+ ``` On 3.11 that program does not print 2 — it deadlocks, because the second thread blocks on the descriptor's lock while the first waits at the barrier for it. The same snippet demonstrating both behaviours is a compact way to show an interviewer you understand exactly what changed. ## When it matters, and what to do instead **It does not matter** when the function is pure and cheap enough that computing it twice is acceptable waste, which covers most real uses: a parse, a sum, a formatted string, a derived tuple. **It matters** when the getter mutates shared state (counters, registries, caches elsewhere), when it acquires or creates a resource, or when downstream code compares the value by identity. The archiver's partial-failure rollback path is the sharp version: if a `cached_property` lazily opens the rollback journal, two threads entering the rollback concurrently create two journals, one of them unreferenced and never closed, and the rollback that was supposed to restore consistency leaves a leaked handle and a half-written file behind. A 340-case regression pack run single-threaded will never see it. The remedies, in the order I would reach for them: 1. **Make the getter pure.** Move the side effect out; cache the derived data only. This removes the problem rather than managing it, and keeps the fast lock-free read. 2. **Build eagerly.** If the resource must exist exactly once, create it in `__init__` under the construction path you already control. Laziness is rarely worth a concurrency contract. 3. **Guard it yourself** with a per-instance `threading.Lock` inside a plain `property`, using the same double-checked shape 3.11 had — but per instance, so unrelated objects do not contend. You are choosing to pay one lock per object because *this* value needs at-most-once semantics. 4. **Publish deliberately.** Compute outside any lock, then use one atomic assignment as the publication point, and have all readers use the stored value rather than their own local result. ## The free-threaded angle On the free-threaded build — experimental in 3.13, officially supported in 3.14 — the two computations genuinely run in parallel rather than interleaving at bytecode boundaries, so the window in which both threads observe a cold cache is wider and duplicate execution becomes correspondingly easier to hit. Nothing about `cached_property` changes; what changes is how often the existing race pays out. Code that quietly relied on the GIL making a short getter "effectively atomic" is exactly the code to re-examine when you evaluate that build. The compressed answer: **the lock is gone since 3.12, so `cached_property` promises caching, not at-most-once computation — pure getters are fine, side-effecting ones need your own synchronization.**
- Why was the old lock a problem if it made the computation safe?It lived on the descriptor, which is one object per class attribute, so every instance contended for it. Threads working on entirely unrelated objects serialized on a single lock, and a getter that blocked on anything held by another thread inside the same descriptor could deadlock. It bought at-most-once semantics at a class-wide throughput cost that most callers never wanted.
- After a duplicate computation, which value do the racing threads actually see?Each racing thread returns the value it computed itself, while the instance dictionary keeps whichever assignment landed last — so two threads can hold two different objects and every later reader sees the stored one. With equal plain values that is invisible; with a resource or anything compared by identity, it is a genuine bug.
- Does the free-threaded build change what cached_property guarantees?No — the guarantees are identical. What changes is the probability: without the GIL the two getters run genuinely in parallel rather than interleaving at bytecode boundaries, so the window where both threads see a cold cache is wider. Code that quietly depended on a short getter being effectively atomic is what to re-examine before moving to that build.
It is a shop with no queue: two customers can walk in for the last item at once, both get one made, and only one ends up on the shelf.
saying these in an interview costs you the question
- Claiming cached_property is thread-safe and computes exactly once
- Saying the removed lock was per instance
- Assuming the GIL prevents the duplicate computation
- Treating a resource-creating getter as safe to cache lazily
- Believing all racing threads end up with the stored value
- Proposing a class-wide lock as the fix without noting the contention