Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

cached_property runs twice in Python 3.12? Add single-flight only when correctness requires it

Python 3.12 removed cached_property’s old descriptor-wide lock. First prove duplicate work is harmful; then use a per-instance lock that stores only a successful result.

Decision tree for choosing ordinary cached_property or per-instance single-flight based on concurrent access and idempotence.
Original explanatory illustration: add synchronization only when duplicate first-access work changes correctness or unacceptable cost. Watch at Ease

A cached value can remain structurally coherent while its getter runs twice. Python’s documentation uses thread-safe in that narrow cache-integrity sense; it does not promise that the wrapped work executes once. Since Python 3.12, two threads can enter the same uncached property on one instance before either result is stored.

Do not add a lock reflexively. If the getter is a pure, idempotent derivation, duplicate work may only cost time. If it charges, creates a file, allocates an external identifier, mutates shared state or returns values whose identity matters, treat call-once behavior as a separate requirement.

1. Prove the race before changing the design

Reproduce first access with two threads synchronized by a barrier. Increment a counter at the start of the getter, pause briefly, then read the property from both threads. On Python 3.12 or later, a count of two is permitted even when both callers finally observe the same cached value.

Record the Python version, whether both callers share one instance, and whether a framework substitutes its own cached_property. A test that creates two separate instances proves a different problem.

2. Classify the getter by consequence, not by cost alone

Pure calculations over immutable instance fields are normally idempotent: running twice produces equivalent results without external effects. Expensive work can still justify coalescing, but that is a performance decision.

Database inserts, payments, file creation, outbound requests, counters and one-time registration are correctness-sensitive. Move irreversible work behind its own idempotency key or transaction when possible; an in-process lock cannot guarantee exactly once across processes or retries.

3. Store the value while the per-instance lock is held

Use a normal property with a private sentinel, cached field and lock. The fast path returns an existing value without locking. The slow path locks, checks again, computes, assigns, and only then releases the lock.

```python import threading _MISSING = object() class Report: def __init__(self): self._summary = _MISSING self._summary_lock = threading.Lock() @property def summary(self): value = self._summary if value is not _MISSING: return value with self._summary_lock: value = self._summary if value is _MISSING: value = self._build_summary() self._summary = value return value ```

4. Preserve failure and invalidation semantics

Assignment occurs only after _build_summary returns. An exception therefore leaves _summary as the sentinel, so the next access retries. Do not publish a partially initialized mutable object and then finish it outside the lock.

If the cache is invalidatable, invalidate under the same instance lock. Decide whether in-flight readers may keep the old object and document that contract. Never overload None as the sentinel when None is a legitimate result.

5. Do not recreate Python 3.11 contention

A lock stored on the descriptor or class can serialize unrelated Report instances, which repeats the reason Python removed the old behavior. A lock created inside every access protects nothing because each thread acquires a different lock.

Per-instance locks cost one lock per object. If that overhead is unacceptable, consider eager construction, immutable precomputation, an external cache with request coalescing, or redesigning the side effect. Choose with measurements rather than assuming the decorator supplies single-flight.

6. Accept the fix with four tests

Test two concurrent reads of one instance and assert one computation. Test two instances and assert their computations overlap. Make the first computation raise and assert the next access retries. Finally invalidate under the lock and assert exactly one new successful value is published.

Also run the tests on every supported Python version. The goal is not to emulate an undocumented historical implementation; it is to state and verify the application’s own concurrency contract.

Per-instance single-flight flow: fast sentinel check, acquire lock, check again, compute, assign before release.
Original explanatory illustration: the second check and in-lock assignment close the duplicate-initialization race. Watch at Ease