ShopperCove
Menu
All writingBlogTopicsCategoriesAboutRSS
Blog
Categories
Observability & SRE62All categories
About

Plate 29

  1. Blog

functools.cache vs lru_cache(None): Localhost Lab

Aditya Challa·1 October 2026·4 min read

Summary
On this page
  1. Intro — what this post promises
  2. Arms
  3. Lab topology
  4. Lead table — warm poly (5000 args, p50)
  5. Hit micro & fib warm
  6. Alias clarity
  7. Cold vs warm
  8. Reading it
  9. When manual dict still wins
  10. Threading caveat
  11. Bounded sibling
  12. Pitfalls
  13. Reproduce
  14. Limits
  15. Takeaway

Intro — what this post promises

Memoize with functools.cache, lru_cache(maxsize=None), and a manual dict. This lab reports warm calls/s on Linux localhost.

It is not lru-cache hit/miss sizing (lab 52). Focus: unbounded cache vs the cache() alias vs hand-rolled dict.

Related links:

  • lru cache hit miss localhost lab
  • graphlib topo vs manual localhost lab
  • tomllib vs json localhost lab
  • path glob vs fnmatch localhost lab
  • heapq merge vs sorted localhost lab
  • islice vs list slice localhost lab
  • dataclass replace vs manual localhost lab
  • zoneinfo vs utc offset localhost lab

Lab honesty (1 Oct 2026 IST): Python 3.13.5. Affiliates: 0. Warm paths after fill.

Verdict up front: poly warm (5000 keys) — cache ~23517348 calls/s ≈ lru_cache(None) ~23540822 > manual ~14943394; single-key hits — lru ~28187316, cache ~25639379, manual ~11053470.


Arms

ArmNotes
@cacheunbounded; alias of lru_cache(maxsize=None)
@lru_cache(maxsize=None)same unbounded semantics
manual dictexplicit in / store
fib(30) recursivecold fill + warm
poly over range(5000)many keys
hit same arg ×200klookup micro

Lab topology

fib N=30 · poly 5000 args · hit inner 200k · 7 rounds · p50 warm
metric: calls/s = work_units / p50_s

Script: lab-evidence/115-functools-cache-vs-lru/results/run_lab.py.


Lead table — warm poly (5000 args, p50)

Armcalls/s
lru_cache(None)23540822
functools.cache23517348
manual dict14943394

Hit micro & fib warm

Armcalls/s
hit lru_cache(None)28187316
hit functools.cache25639379
hit manual dict11053470
fib cache warm8695916
fib lru warm7407511
fib manual warm6134846

cache and lru_cache(None) track each other — expected, since cache is documented as that unbounded wrapper.


Alias clarity

In CPython, functools.cache is implemented as lru_cache(maxsize=None). Treat them as one mechanism with two spellings — pick cache for readability unless you already standardize on lru_cache across the codebase.


Cold vs warm

Cold fib fills ~N entries quickly; warm calls are essentially a single hash lookup returning the root. Poly cold must insert 5000 keys once — after that, wrappers stay ahead of a pure-Python if x in d loop on this box.


Reading it

  • Prefer @cache for unbounded pure memo (clearer than maxsize=None).
  • Need eviction → sized lru_cache (lab 52 for hit/miss sizing).
  • Manual dict only when you need custom keys/eviction/metrics.
  • Typed/unhashable args still fail — wrappers need hashables.

When manual dict still wins

Custom composite keys, soft references, metrics counters, or per-tenant partitions often force an explicit dict (or WeakValueDictionary — see the weakref cache lab). For plain hashable args and unbounded memo, prefer @cache and skip maintaining your own if k in d boilerplate.


Threading caveat

lru_cache / cache are generally fine for concurrent reads of already-populated entries, but fill storms and cache_clear need care in multi-threaded servers. This lab is single-threaded warm lookups only — do not extrapolate to lock-free claims.


Bounded sibling

If RSS growth is the fear, switch to lru_cache(maxsize=…) and tune with lab 52’s hit/miss methodology. Unbounded wrappers here intentionally match “remember everything” workloads, not the eviction design space.


Pitfalls

  • Unbounded growth on huge key spaces (memory leak).
  • Caching impure functions (time, I/O, globals).
  • Comparing to lab 52’s bounded hit/miss story without noting maxsize.
  • Clearing caches in tests but not in long-running workers.

Reproduce

python3 lab-evidence/115-functools-cache-vs-lru/results/run_lab.py

Evidence: summary.json, summary.txt.


Limits

One Linux box. Hashable int keys only. Not threaded cache contention. Not typed=True variants.


Takeaway

Warm poly lookups: cache ≈ lru_cache(None) ~23517348 calls/s beat manual ~14943394. Use @cache for unbounded memo; use sized lru_cache when you need eviction (see lab 52).

pythonfunctoolscachelru_cachememoizationperformancebenchmarking

Lab evidence

What I found running this

Ran the supplied run_lab.py on Linux localhost with Python 3.13.5, warming cache paths after fill for 7 p50 rounds. The poly workload used 5000 keys; repeated-hit microbench used 200k inner hits; fib used N=30. Warm poly measured lru_cache(None) at 23,540,822 calls/s, functools.cache at 23,517,348, and manual dict at 14,943,394. Single-key hits favored lru_cache, while cache and lru_cache tracked closely as expected aliases.

Notes when a lab post goes up

Occasional email for new hands-on reviews. No sequence and no sponsors.

Related links

  • Plate 34

    ast.literal_eval vs json.loads: Localhost Lab

    1 Oct 2026

  • Plate 68

    gc.collect Cost Empty vs Cycles: Localhost Lab

    Hands-on gc.collect cost empty vs cyclic garbage lab: real collect latency plus reclaim counts, measured on Linux localhost today in this lab for SREs.

    Observability & SRE · 1 Oct 2026

  • Plate 76

    html.parser vs regex Tag Strip: Localhost Lab

    1 Oct 2026

On this page

  1. Intro — what this post promises
  2. Arms
  3. Lab topology
  4. Lead table — warm poly (5000 args, p50)
  5. Hit micro & fib warm
  6. Alias clarity
  7. Cold vs warm
  8. Reading it
  9. When manual dict still wins
  10. Threading caveat
  11. Bounded sibling
  12. Pitfalls
  13. Reproduce
  14. Limits
  15. Takeaway
All writingBlogCategoriesTopicsAboutPrivacyRSS

© 2026 ShopperCove