Cached Properties¶
Mental Model
A cached property is a lazy, self-destructing descriptor. On first access it computes the value, stores it in the instance's __dict__, and from that point on the descriptor is never called again -- the instance attribute shadows it. Think of it as a "compute once, remember forever" pattern: the first read does the work, every subsequent read is a plain dictionary lookup.
Concept¶
1. What Is Caching?¶
A cached property computes its value once on first access, stores the result inside the instance (usually in __dict__), and on future accesses, just returns the cached value — skipping recomputation. The key mental model: a cached property starts as a descriptor but after the first access, it replaces itself with a plain attribute in the instance. From that point on, the descriptor is no longer involved.
text
@property: always intercepts → always runs getter
cached_property: intercepts once → then becomes a normal attribute
2. When to Use¶
Use cached properties when:
- Computation is expensive (database queries, heavy calculations)
- Result doesn't change after first computation
- Want to trade memory for speed
- Need lazy evaluation
3. Built-in vs Custom¶
- Python ≥ 3.8:
@functools.cached_property - Custom: Build your own using descriptors
Relationship to property
cached_property is a non-data descriptor — it defines __get__ but not __set__. On first access, __get__ computes the value and stores it directly in the instance's __dict__. On subsequent accesses, the instance __dict__ entry is found before the descriptor (because non-data descriptors have lower priority), so the cached value is returned without calling __get__ again. This is the opposite of @property, which is a data descriptor that always intercepts access.
Custom Implementation¶
1. Descriptor Class¶
```python class CachedProperty: def init(self, func): self.func = func self.doc = func.doc self.name = func.name
def __get__(self, instance, owner):
if instance is None:
return self
if self.name not in instance.__dict__:
print(f"Computing and caching: {self.name}")
instance.__dict__[self.name] = self.func(instance)
else:
print(f"Using cached value: {self.name}")
return instance.__dict__[self.name]
```
2. Using Custom Descriptor¶
```python import math
class Circle: def init(self, radius): self.radius = radius
@CachedProperty
def area(self):
print("Calculating area...")
return math.pi * self.radius ** 2
@CachedProperty
def perimeter(self):
print("Calculating perimeter...")
return 2 * math.pi * self.radius
```
3. Behavior Example¶
```python c = Circle(10)
print(c.area) # → Calculates and caches
Output: Computing and caching: area¶
Calculating area...¶
314.159...¶
print(c.area) # → Uses cached value
Output: Using cached value: area¶
314.159...¶
```
How It Works¶
1. Initialization¶
When you decorate:
python
@CachedProperty
def area(self):
...
You are doing at class definition time:
python
Circle.area = CachedProperty(area)
The CachedProperty.__init__ stores:
self.func = area→ original methodself.__doc__ = area.__doc__→ docstringself.name = 'area'→ attribute name
2. First Access¶
When you do c.area, Python calls:
python
Circle.area.__get__(c, Circle)
Inside __get__:
- Check if
instance is None(class access) → return descriptor - Check if
'area'ininstance.__dict__→ not yet - Compute:
instance.__dict__['area'] = self.func(instance) - Return cached value
3. Subsequent Access¶
When you do c.area again:
- Check if
instance is None→ no - Check if
'area'ininstance.__dict__→ yes, found! - Skip computation, return cached value directly
After the first access, the descriptor is no longer involved. The value is returned directly from instance.__dict__ because instance __dict__ entries take priority over non-data descriptors in Python's attribute lookup. Deleting the cached attribute (del c.area) removes the __dict__ entry, causing the descriptor to run again on next access.
Built-in Version¶
1. Using functools¶
Python 3.8+ provides built-in cached property:
```python from functools import cached_property
class Circle: def init(self, radius): self.radius = radius
@cached_property
def area(self):
print("Computing area...")
return math.pi * self.radius ** 2
```
2. Behavior¶
python
c = Circle(10)
print(c.area) # Computing area... 314.159...
print(c.area) # 314.159... (no recomputation)
3. Clearing Cache¶
To clear cached value:
python
del c.area # Remove from instance.__dict__
print(c.area) # Recomputes
Practical Examples¶
1. Database Query¶
```python class User: def init(self, user_id): self.user_id = user_id
@cached_property
def profile(self):
print(f"Fetching profile for user {self.user_id}...")
# Expensive database query
return db.query(f"SELECT * FROM users WHERE id={self.user_id}")
```
2. File Reading¶
```python class ConfigFile: def init(self, path): self.path = path
@cached_property
def data(self):
print(f"Reading {self.path}...")
with open(self.path) as f:
return f.read()
```
3. Complex Computation¶
```python class Matrix: def init(self, data): self.data = data
@cached_property
def determinant(self):
print("Computing determinant...")
# Expensive calculation
return self._compute_determinant()
```
Comparison Table¶
1. Property vs Cached Property¶
| Feature | @property |
@cached_property |
|---|---|---|
| Computation | Every access | Once on first access |
| Storage | Not stored | Stored in __dict__ |
| Speed | Slower | Faster after first |
| Memory | Less | More |
| Use case | Dynamic values | Static expensive values |
2. When to Choose¶
Use @property when:
- Value changes over time
- Computation is cheap
- Need fresh value each time
Use @cached_property when:
- Value is constant after creation
- Computation is expensive
- Multiple accesses expected
3. When NOT to Use @cached_property¶
- Mutable underlying state: if the attributes the cached property depends on can change after construction (e.g.,
self.radiusis modified), the cached value becomes stale and silently wrong. - Thread safety:
functools.cached_propertyis not thread-safe. If multiple threads access the property simultaneously before it is cached, the computation may run multiple times. Usethreading.Lockif this matters. - Side effects: avoid caching properties that perform I/O or modify external state --- the user expects repeated access to be harmless.
Exercises¶
Exercise 1. Write a DataSet class that accepts a list of numbers. Add a @cached_property called stats that returns a dictionary with "mean", "min", and "max". Verify that the computation runs only once across multiple accesses.
Solution to Exercise 1
```python from functools import cached_property
class DataSet: def init(self, numbers): self.numbers = numbers
@cached_property
def stats(self):
print("Computing stats...")
return {
"mean": sum(self.numbers) / len(self.numbers),
"min": min(self.numbers),
"max": max(self.numbers),
}
ds = DataSet([10, 20, 30, 40, 50]) print(ds.stats) # Computing stats... {'mean': 30.0, 'min': 10, 'max': 50} print(ds.stats) # {'mean': 30.0, 'min': 10, 'max': 50} (no recomputation) ```
Exercise 2. Predict the output of the following code:
```python from functools import cached_property
class Greeter: def init(self, name): self.name = name
@cached_property
def message(self):
print("Computing message")
return f"Hello, {self.name}!"
g = Greeter("Alice") print(g.message) g.name = "Bob" print(g.message) ```
Solution to Exercise 2
The output is:
Computing message
Hello, Alice!
Hello, Alice!
The message cached property computes once on first access using name = "Alice". Even though name is later changed to "Bob", the cached value is never recomputed. cached_property stores the result in the instance's __dict__, and subsequent accesses return the cached string. This is a key difference from @property, which would recompute each time.
Exercise 3. Implement a custom CachedProperty descriptor class (without using functools.cached_property). It should compute the value on first access, store it in the instance's __dict__, and return the cached value on subsequent accesses. Test it with a class that computes the factorial of a stored number.
Solution to Exercise 3
```python import math
class CachedProperty: def init(self, func): self.func = func self.name = func.name
def __get__(self, instance, owner):
if instance is None:
return self
if self.name not in instance.__dict__:
instance.__dict__[self.name] = self.func(instance)
return instance.__dict__[self.name]
class FactorialComputer: def init(self, n): self.n = n
@CachedProperty
def factorial(self):
print(f"Computing {self.n}!")
return math.factorial(self.n)
fc = FactorialComputer(10) print(fc.factorial) # Computing 10! -> 3628800 print(fc.factorial) # 3628800 (cached) ```
Exercise 4. Using functools.cached_property, create a FileAnalyzer class that takes a file path and has cached properties line_count and word_count. Demonstrate that deleting the cached property forces recomputation on next access.
Solution to Exercise 4
```python from functools import cached_property
class FileAnalyzer: def init(self, path): self.path = path
@cached_property
def line_count(self):
print("Counting lines...")
with open(self.path) as f:
return sum(1 for _ in f)
@cached_property
def word_count(self):
print("Counting words...")
with open(self.path) as f:
return sum(len(line.split()) for line in f)
Demonstration (assuming a file exists):¶
fa = FileAnalyzer("example.txt")¶
print(fa.line_count) # Counting lines... 42¶
print(fa.line_count) # 42 (cached)¶
del fa.line_count # Clear cache¶
print(fa.line_count) # Counting lines... 42 (recomputed)¶
```
Exercise 5. Explain the key difference between @property and @cached_property in terms of when computation happens. Give a concrete example of when using @property would be more appropriate than @cached_property.
Solution to Exercise 5
@property recomputes the value every time the attribute is accessed. @cached_property computes the value once on first access and caches it in the instance's __dict__.
Use @property when the value depends on mutable state that may change between accesses:
```python class Rectangle: def init(self, width, height): self.width = width self.height = height
@property
def area(self):
return self.width * self.height
r = Rectangle(3, 4) print(r.area) # 12 r.width = 10 print(r.area) # 40 (correctly recomputed) ```
If area were a @cached_property, it would still return 12 after changing width, which would be incorrect. Use @cached_property only when the underlying data is immutable after construction.