LESSON 130 · DEEPER PYTHON30 min

Cache Derived Values with cached_property

Cache an expensive derived value and invalidate it whenever its inputs change.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Cache Derived Values with cached_property

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    A report computes a total from a list of values. Recomputing the same total on every access wastes work, so functools.cached_property stores the first result on the instance. The next access retrieves the stored value without calling the method again. The first example increments a calls counter inside a cached_property method. Reading answer twice still leaves calls at one. That is the value of the decorator: repeated reads return the stored result rather than repeating work. The result is written onto the instance, so a different instance would have its own cache. A cache is a remembered answer, not a relationship-tracking system.

  2. 00:41

    Once total is stored, changing the underlying list does not notify cached_property. We can show both the speed benefit and the stale-value hazard. Before the first run, make a concrete prediction about the terminal output. A prediction turns a passive demonstration into a test: we can compare the actual result with the rule we thought the code followed. Keep the input small enough to inspect every value. The cache becomes wrong when the underlying values change. A broken example appends another number but still prints the original total. The decorator cannot know which mutations matter; the class that owns the state must clear the cached attribute after a successful update.

  3. 01:25

    Caching trades repeated computation for a responsibility to know when the inputs change. The example's answer never changes, but Report.total depends on a mutable list. Before adding invalidation, run the broken starter and observe that adding four changes values but leaves the displayed total at five. The broken report first prints five and then prints five again after adding four. That output tells us the list changed but the cached attribute did not. The repair belongs where the controlled mutation happens. Watch the output as evidence rather than jumping directly to a replacement snippet.

  4. 02:05

    The broken program may exit cleanly while still violating its contract. We will name the exact input, the observed result, and the result we wanted before changing code. A cached_property writes into an instance's __dict__, unlike an ordinary read-only property. Deleting the cached attribute makes the next access recompute it. For a method that may run before the property was ever read, self.__dict__.pop('total', None) safely removes it if present. Validate the new number first, then mutate and invalidate together. The second example changes Score.value from two to three. Without deletion, doubled would remain four; deleting s.doubled makes the next

  5. 02:46

    access calculate six. Deletion is an explicit signal that the previous answer is no longer trustworthy. It is not a general cache reset for unrelated attributes. The add method should first reject values outside its contract, then append, then clear the cached total. If validation fails, no mutation or invalidation should occur. This ordering makes rejected calls easy to reason about and ensures a successful change cannot expose a stale result. The diagnostic run counts method calls. It proves why the decorator exists: two reads cost one computation.

  6. 03:27

    The goal is to preserve that benefit between updates while forcing a fresh calculation after an update. The intermediate run isolates one mechanism. It is not the entire solution, and that is intentional. A small diagnostic is easier to explain than a large corrected application because it shows which assumption failed and which Python behavior we can rely on. Keep Report.total as a cached_property. Make add(value) require a non-boolean integer, append it, and invalidate the cached total so the prepared run prints 5 then 9. An invalid value must leave the report unchanged.

  7. 04:08

    After validating and appending, call self.__dict__.pop('total', None). Validate before changing values. Using del self.total in add assumes the property has been read before. A caller may instead create a report and immediately add a number; there is no cached attribute to delete yet. The third example uses __dict__.pop with a default, which quietly does nothing when the key is absent. Use pop with a default when clearing the cache; add might run before total was ever read. The validation happens before append, and invalidation follows append. That sequence keeps a rejected update from changing either state or cache.

  8. 04:49

    After the fix, compare the observed output with the original prediction. The code shown on screen is executed locally and the terminal pane replays that captured run. The linked lesson gives you a separate checked task, so you can apply the idea rather than only copy a slide. This technique fits derived data that is expensive to calculate and changes only at controlled points. Public mutable attributes let callers bypass invalidation, so production code should consider private state or an immutable design. Classes without a writable __dict__ need a different caching approach. The checked task demonstrates the visible stale-value bug and its repair.

  9. 05:32

    This makes invalidation safe in either order, but it does not solve hidden mutations. If callers can reach Report.values and append directly, the class never hears about the change. Encapsulate the list or prefer immutable values when stronger guarantees are needed. The checked task focuses on the controlled add method. Clearing the cached attribute causes recomputation on the next read. Keep state transitions together and predictable. The last run adds before first access, then adds again after caching. Both paths should produce correct totals. The lesson task also rejects booleans and floats under its integer-only contract.

  10. 06:14

    An edge case is more valuable than repeating the same happy path. The final run challenges a boundary or invalid input and explains the decision the function makes. If your own implementation disagrees, use the lesson console to narrow the smallest failing case.

Understand the concept

A report computes a total from a list of values. Recomputing the same total on every access wastes work, so functools.cached_property stores the first result on the instance. The next access retrieves the stored value without calling the method again.

The cache becomes wrong when the underlying values change. A broken example appends another number but still prints the original total. The decorator cannot know which mutations matter; the class that owns the state must clear the cached attribute after a successful update.

A cached_property writes into an instance's __dict__, unlike an ordinary read-only property. Deleting the cached attribute makes the next access recompute it. For a method that may run before the property was ever read, self.__dict__.pop('total', None) safely removes it if present. Validate the new number first, then mutate and invalidate together.

This technique fits derived data that is expensive to calculate and changes only at controlled points. Public mutable attributes let callers bypass invalidation, so production code should consider private state or an immutable design. Classes without a writable __dict__ need a different caching approach. The checked task demonstrates the visible stale-value bug and its repair.

  • First access computes; later accesses reuse
  • Mutation requires explicit invalidation
  • cached_property stores on the instance
  • Encapsulate inputs that affect the cache

See it step by step

Read the code, predict the output, then compare it with the result.

01. Observe caching

PYTHON
from functools import cached_property
class Example:
    calls=0
    @cached_property
    def answer(self):
        self.calls += 1
        return 42
e=Example()
print(e.answer,e.answer,e.calls)
EXPECTED OUTPUT
42 42 1

Two reads trigger only one calculation.

02. Delete to recompute

PYTHON
from functools import cached_property
class Score:
    def __init__(self): self.value=2
    @cached_property
    def doubled(self): return self.value*2
s=Score()
print(s.doubled)
s.value=3
del s.doubled
print(s.doubled)
EXPECTED OUTPUT
4
6

Deleting the cached attribute makes the next read calculate again.

03. Safely clear an absent cache

PYTHON
class Box:
    pass
b=Box()
print(b.__dict__.pop('result', None))
EXPECTED OUTPUT
None

pop with a default works even before the property has been read.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Observe one computation for two reads

The first example increments a calls counter inside a cached_property method. Reading answer twice still leaves calls at one. That is the value of the decorator: repeated reads return the stored result rather than repeating work. The result is written onto the instance, so a different instance would have its own cache.

Caching trades repeated computation for a responsibility to know when the inputs change. The example's answer never changes, but Report.total depends on a mutable list. Before adding invalidation, run the broken starter and observe that adding four changes values but leaves the displayed total at five.

PYTHON
from functools import cached_property
class Example:
    calls=0
    @cached_property
    def answer(self):
        self.calls += 1
        return 42
e=Example()
print(e.answer,e.answer,e.calls)
EXPECTED OUTPUT
42 42 1
Follow the reasoning
  1. Count how often the property body executes.
  2. Read the same attribute twice.
  3. Compare this with the mutable Report input.
02 / 03

Delete the cached attribute after a change

The second example changes Score.value from two to three. Without deletion, doubled would remain four; deleting s.doubled makes the next access calculate six. Deletion is an explicit signal that the previous answer is no longer trustworthy. It is not a general cache reset for unrelated attributes.

The add method should first reject values outside its contract, then append, then clear the cached total. If validation fails, no mutation or invalidation should occur. This ordering makes rejected calls easy to reason about and ensures a successful change cannot expose a stale result.

PYTHON
from functools import cached_property
class Score:
    def __init__(self): self.value=2
    @cached_property
    def doubled(self): return self.value*2
s=Score()
print(s.doubled)
s.value=3
del s.doubled
print(s.doubled)
EXPECTED OUTPUT
4
6
Follow the reasoning
  1. Read and cache the original value.
  2. Change the input, then delete the cached attribute.
  3. Read again and verify recomputation.
03 / 03

Handle an update before the first read

Using del self.total in add assumes the property has been read before. A caller may instead create a report and immediately add a number; there is no cached attribute to delete yet. The third example uses __dict__.pop with a default, which quietly does nothing when the key is absent.

This makes invalidation safe in either order, but it does not solve hidden mutations. If callers can reach Report.values and append directly, the class never hears about the change. Encapsulate the list or prefer immutable values when stronger guarantees are needed. The checked task focuses on the controlled add method.

PYTHON
class Box:
    pass
b=Box()
print(b.__dict__.pop('result', None))
EXPECTED OUTPUT
None
Follow the reasoning
  1. Try pop on an absent key.
  2. Call add before the first total read.
  3. Test repeated reads after an update.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnKeep Report.total as a cached_property. Make add(value) require a non-boolean integer, append it, and invalidate the cached total so the prepared run prints 5 then 9. An invalid value must leave the report unchanged.
Need a hint?

After validating and appending, call self.__dict__.pop('total', None). Validate before changing values.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.When is a cached_property method first called?

    02.What makes a cached total stale?

    03.Why validate before appending?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Forgotten invalidation

    COMMON MISTAKE
    self.values.append(value)
    FIX
    self.values.append(value); self.__dict__.pop('total', None)

    What happens: The old cached total remains visible.
    Clearing the cached attribute causes recomputation on the next read.

    Clearing before validation

    COMMON MISTAKE
    self.__dict__.pop('total', None); self.values.append(value)
    FIX
    Validate first, then mutate and invalidate

    What happens: A rejected update can still alter cache state.
    Keep state transitions together and predictable.

    Browse this lesson’s problem fixes

    Sources and further reading