ShopperCove
Menu
All writingBlogTopicsCategoriesAboutRSS
Blog
Categories
Observability & SRE62All categories
Topics
sre111localhost lab102performance70python58javascript47bundle size36benchmarking35node.js35web development21typescript19benchmark16content creation15All topics
About
Loading article…

ShopperCove

Hands-on notes on developer tools, written after the tool has actually been run.

All writingBlogCategoriesTopicsAboutPrivacyRSS

© 2026 ShopperCove

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
  16. FAQ
  17. Is functools.cache the same as lru\_cache?
  18. Is lru\_cache faster than a manual dict?
  19. When should I use a sized lru\_cache instead?
  1. Blog

lru_cache vs functools.cache: Is There Any Difference?

1 October 2026·4 min read

Summary

Disclosure

This post contains affiliate links. If you buy through them, I may earn a commission at no extra cost to you. I only make firsthand claims about products I have actually used.

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
  16. FAQ
  17. Is functools.cache the same as lru\_cache?
  18. Is lru\_cache faster than a manual dict?
  19. When should I use a sized lru\_cache instead?

Intro — what this post promises

There is no real difference between functools.cache and lru_cache(maxsize=None): in CPython, cache is implemented as lru_cache(maxsize=None), and on Python 3.13.5 both ran about 23.5M warm calls/s over 5,000 keys, against about 14.9M for a hand-written dict. Use @cache for unbounded memoization because it reads more clearly, and switch to a sized lru_cache(maxsize=...) when memory growth means you need eviction.FAQ

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

Related links:

  • 01shoppercove.comlru cache hit miss localhost lab
  • 02shoppercove.comgraphlib topo vs manual localhost lab
  • 03shoppercove.comtomllib vs json localhost lab
  • 04shoppercove.compath glob vs fnmatch localhost lab
  • 05shoppercove.comheapq merge vs sorted localhost lab
  • 06shoppercove.comislice vs list slice localhost lab
  • 07shoppercove.comdataclass replace vs manual localhost lab
  • 08shoppercove.comzoneinfo 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).

FAQ

Is functools.cache the same as lru_cache?

Yes. functools.cache is lru_cache(maxsize=None), an unbounded cache with no eviction. In this lab they tracked each other: 23,517,348 vs 23,540,822 warm calls/s.

Is lru_cache faster than a manual dict?

Here, yes. Warm lookups over 5,000 keys ran at about 23.5M calls/s with the decorators vs about 14.9M with an if-key-in-dict loop. Single-key hits were 28.2M (lru_cache) vs 11.1M (dict).

When should I use a sized lru_cache instead?

When the key space is large or comes from user input. An unbounded cache keeps every key and grows memory, so set maxsize to cap it.

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.

Explore the product

Untested by us. Check what's included, the license and refund terms before you buy.

Ready Client Sites for prebuilt websites

See Ready Client Sites details

Notes when a lab post goes up

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

Explore the product

Untested by us. Check what's included, the license and refund terms before you buy.

Ready Client Sites for prebuilt websites

See Ready Client Sites details

Keep reading

  • ast.literal_eval vs json.loads: Localhost Lab

    Summary1 Oct 20263 min read

  • 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.

    Hands-on1 Oct 20264 min read

  • html.parser vs regex Tag Strip: Localhost Lab

    Hands-on1 Oct 20264 min read