cld-toys › Toys › lru-cache

Commentary: lru-cache

LFU beats LRU by 11 points — until the popular set shifts once, and LFU falls to 4.16% and never comes back. A study guide for lru_cache.py.

lru-cache/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with lru_cache.py open beside you. lru_cache.py is the toy itself (166 lines, two policies); demo.py drives both with identical traces; test_lru_cache.py locks every number on this page in. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0), arm64 Apple Silicon, Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd lru-cache
python3 demo.py            # the aha (§6)
python3 test_lru_cache.py  # pins every number this page quotes
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy implements two cache eviction policies — LRU (least recently used) and LFU (least frequently used) — each in O(1) per operation, and feeds them byte-identical request traces.

On a stationary popularity distribution, LFU wins comfortably, which is what everyone expects: frequency is more information than recency, so a policy that uses it should do better. Then the popular set shifts once. Same code, same capacity, same 90/10 traffic shape, twenty hot keys swapped for twenty different ones. LRU doesn't notice. LFU falls from 87.21% to 4.16% and never recovers — not in the 20,000 requests that follow, and not in 400,000.

By the end you should be able to:

The last one is the payoff. "Our cache hit rate fell off a cliff and never came back" is a production incident, and its cause is usually one of the two shapes on this page.


2. The problem this mechanism exists to solve

A cache is a bet: that a small fast store, holding a fraction of a large slow store, can serve most of the requests. The bet is only good if the fraction you keep is the fraction you'll be asked for. Since capacity is finite and the request stream is not, every cache must continuously answer one question:

The only question a cache actually answers When a new item arrives and there is no room, which resident dies?

That decision is the eviction policy, and it is the whole cache. Everything else — the hash map, the memory layout, the wire protocol — is bookkeeping. The theoretically perfect answer is known and useless: Bélády's MIN algorithm evicts the item whose next use is furthest in the future, which requires knowing the future. Every real policy is a heuristic that guesses at MIN from the past, and they differ in which summary of the past they keep:

These pull against each other, and the tension is not academic. A cache that weights recency is fooled by a one-off scan; a cache that weights frequency is fooled by yesterday's hits. The observation this toy is built to force:

The observation this toy is built to force Every eviction policy is a theory of what the past predicts, and its failure mode is exactly the workload where that theory is false. You cannot pick a policy from its average hit rate. You pick it from the shape of the traffic that will break it.

3. Background you need

None of it is deep, but the commentary leans on all of it.

ConceptWhere it's used hereOne source
Doubly linked list LRUCache's ordering. Two links per node are what make "remove this node" O(1) with no traversal — a singly linked list would need to find the predecessor Doubly linked list
Sentinel nodes self.head / self.tail in LRUCache.__init__. They hold no data and exist only so _unlink never sees a None neighbour Cache replacement policies
Bucketed counting for O(1) min LFUCache.buckets plus min_count. Tracking the minimum incrementally is what avoids a min() scan over every resident on every eviction Cache replacement policies § LFU
Zipf / power-law popularity demo.zipf_trace, the stationary trace in §6.1. Key i is drawn with weight proportional to 1/is Zipf's law
Bélády's anomaly and stack algorithms §9 Q4: whether a bigger cache can ever mean fewer hits Bélády's anomaly

The two that carry the result are the first and the third — not because the collapse is a data-structure bug, but the opposite: both structures are correct and O(1), and the failure is entirely in the ordering they compute. If you internalise one row, take the third: min_count and the buckets are what make LFU's arithmetic visible, and once you can see the counts, the collapse in §6 is not surprising at all — it is subtraction.


4. The mental model

Before any code. Both caches answer "which resident dies?", but they keep different summaries of the past, and the summary is the policy.

LRU — order IS the state. head tail │ most recently used least recently used│ ▼ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ▼ [H] <---> │ k4│<->│ k1│<->│ k9│<->│ k2│<->│ k7│ <------> [T] ▲ └───┘ └───┘ └───┘ └───┘ └───┘ ▲ sentinel ▲ │ sentinel │ └── evict here map: {k4:•, k1:•, k9:•, k2:•, k7:•} a hash lookup lands you ON the node, so unlinking is O(1) get(k9) -> unlink k9, push to front. A READ MUTATES THE ORDER. LFU — a counter per key, and one bucket per counter value. counts: {k4: 851, k1: 903, k9: 851, k2: 1, k7: 938} buckets: 1 -> {k2} <- min_count = 1, evict from here 851 -> {k4, k9} <- insertion order breaks the tie: k4 dies 903 -> {k1} 938 -> {k7} get(k9) -> move k9 from bucket 851 to bucket 852. Its count NEVER falls.

The slogan, before you read a line of code:

Everything in §6 follows from that last clause.


5. Reading the source

166 lines, two classes, no imports. Read them in this order.

5.1 The determinism decision, in the docstring

lru_cache.py · lines 1–9
"""Two cache eviction policies, side by side: LRU (least-recently-used) and
LFU (least-frequently-used). Both expose the same interface — get(key) and
put(key, value) — and both count hits, misses and evictions.

Neither reads a clock nor an RNG. Eviction order is a pure side effect of the
data structure: for LRU, position in a doubly linked list; for LFU, an
integer count plus insertion order within a count. Same trace, same numbers,
every run.
"""

The most consequential decision in the toy is the one it doesn't make: there is no clock in this file, and no randomness. "Least recently used" sounds like it needs timestamps, and plenty of real implementations use them (Redis stores a 24-bit clock per object). This one doesn't: position in a list is the timestamp, with the ordering and none of the arithmetic.

That buys the thing this whole page depends on. Run demo.py twice and diff it — the output is byte-identical, including a hit rate quoted to two decimal places, because the caches are pure functions of the trace. The only RNG in the toy lives in demo.py behind an explicitly seeded random.Random(SEED), so even the traces are fixed. A cache whose behaviour depends on wall-clock timing can't have a commentary written about it, because no number in it would be checkable.

5.2 Node, and why there are two of them that hold nothing

lru_cache.py · lines 37–46
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}
        # Sentinel head and tail. They hold no data; they exist so _unlink
        # and _push_front never have to check for None neighbours, which is
        # where every hand-written linked list goes wrong.
        self.head = Node()
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head

Two nodes that are never in self.map and never hold a key. They are pure overhead in memory and pure profit in code: with sentinels, every node in the list has a real prev and a real next, always. No first-element case, no last-element case, no empty-list case.

Compare the two versions of the same operation. Without sentinels, removing a node is (this block is not from the source — it's the code the sentinels delete):

if node.prev is None:      # it was the head
    self.head = node.next
    if self.head is not None:
        self.head.prev = None
else:
    node.prev.next = node.next
    ...                    # and the mirror image for the tail

With them, it is §5.3. That is the entire argument for two wasted objects, and it is why Node.__init__ defaults both key and value to None: a sentinel is exactly a node that is never in the map.

5.3 _unlink and _push_front — four lines that are the whole policy

lru_cache.py · lines 51–59
    def _unlink(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _push_front(self, node):
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node

_unlink never searches for the node — it is handed the node, by the hash map, and splices it out in constant time. This is the whole reason the list is doubly linked. In a singly linked list you cannot remove a node you're standing on, because you can't reach its predecessor without walking from the head, and that walk is O(n). The prev pointer costs 8 bytes per entry and buys the O(1) that makes LRU practical at all.

Note also what _push_front does not do: it doesn't touch self.map. The map holds identity ("where is this key?"), the list holds order ("who dies next?"), and the two are updated independently. Every operation below is some combination of these four lines.

5.4 LRUCache.get — the read that mutates

lru_cache.py · lines 61–69
    def get(self, key):
        node = self.map.get(key)
        if node is None:
            self.misses += 1
            return None
        self.hits += 1
        self._unlink(node)
        self._push_front(node)
        return node.value

Two lines in a read path rewrite four pointers. That's the policy: without them you have a cache that evicts in insertion order — FIFO — and a get that tells you nothing about the future. I ran that variant rather than asserting it:

LRUCache (as written) zipf= 31.95% phase1= 73.94% get() does not move the node to the front (FIFO) zipf= 27.36% phase1= 66.00%

Deleting two lines from a read costs 4.6 points on the Zipf trace and 7.9 on the hot/cold one. Load-bearing.

It is also the line with the highest production cost in the file. A read that writes is a read that needs a lock, that dirties a cache line, that cannot be served from a read-only replica. This is precisely why Redis does not do it — it stamps a per-object clock and samples a handful of keys at eviction time instead, trading exactness for never having to maintain a global order.

5.5 LRUCache.put — eviction is just "read the tail"

lru_cache.py · lines 78–85
        if len(self.map) >= self.capacity:
            victim = self.tail.prev
            self._unlink(victim)
            del self.map[victim.key]
            self.evictions += 1
        node = Node(key, value)
        self.map[key] = node
        self._push_front(node)

There is no eviction algorithm. There's no scan, no comparison, no priority queue — the victim is wherever the tail happens to point, and it is correct because every get has been maintaining the invariant all along. The cost of LRU is paid on every read, so that eviction is free. That trade is the design, and §6.5 is the bill.

del self.map[victim.key] is why Node stores its own key. The node knows its position; only the key can find its map entry. Drop that field and the victim is unreachable in the map, and you leak an entry per eviction.

The comparison is >=, not >. With > the cache holds capacity + 1 entries — I checked, and it does exactly that:

`>=` (as written): residents = 20 `>` (variant): residents = 21

A quiet one-key overrun. It would never show up in a hit-rate graph; it shows up in an out-of-memory kill six months later.

5.6 LFUCache.__init__ — three dicts instead of a list

lru_cache.py · lines 108–113
    def __init__(self, capacity):
        self.capacity = capacity
        self.values = {}
        self.counts = {}
        self.buckets = {}  # count -> {key: None}, insertion-ordered
        self.min_count = 0

The naive LFU stores a count per key and, on eviction, calls min(counts, key=counts.get) — O(n) per eviction, which the ARC paper politely records as LFU's "logarithmic implementation complexity" even in its better forms. buckets removes the scan: all keys sharing a count live in one bucket, min_count names the lowest occupied bucket, and eviction is "take something out of buckets[min_count]."

Each bucket is a dict used as an insertion-ordered set — Python dicts have preserved insertion order since 3.7, so next(iter(bucket)) is the key that arrived at that count first. That makes the tie-break deterministic, which matters: at capacity 20 with a 1000-key Zipf trace, ties at low counts are constant, and an arbitrary tie-break would make every number on this page irreproducible. Two alternative rules, run against the same traces:

LFUCache (as written: oldest-at-count wins the tie) zipf= 43.11% phase1= 87.21% tie-break: newest arrival at the min count zipf= 41.17% phase1= 86.98% tie-break: lowest key id at the min count zipf= 40.83% phase1= 86.86%

Worth 2.3 points on the Zipf trace, so it is load-bearing for the numbers — "LFU" alone does not specify a hit rate. It is not load-bearing for the collapse in §6.2, though: all three tie-breaks produce phase 2 = 4.16%, identical to two decimal places. That is a genuinely useful negative result, and I would have guessed wrong: the tie-break decides which of the equally worthless keys revolves through the one free slot, and §6.3 shows why that cannot matter.

5.7 _bump — the line that never decreases

lru_cache.py · lines 118–129
    def _bump(self, key):
        count = self.counts[key]
        bucket = self.buckets[count]
        del bucket[key]
        if not bucket:
            del self.buckets[count]
            # The only bucket that can empty and matter is the minimum one,
            # and the key that just left it went to exactly count + 1.
            if self.min_count == count:
                self.min_count = count + 1
        self.counts[key] = count + 1
        self.buckets.setdefault(count + 1, {})[key] = None

Move one key from bucket c to bucket c+1, and if that emptied the minimum bucket, the new minimum is exactly c+1 — because the key that left went precisely one step up. No search.

The if self.min_count == count guard is doing real work. Raise min_count unconditionally and the cache stops finding its own minimum; eviction then picks victims out of an arbitrary bucket, and LFU degenerates into something notably worse than FIFO:

LFUCache (as written) zipf= 43.11% phase1= 87.21% _bump raises min_count unconditionally zipf= 23.58% phase1= 66.49%

Load-bearing, and it fails silently — the cache still returns correct values, still respects capacity, and simply throws away the right keys. This is the characteristic bug of hand-rolled LFU.

Now read the whole method again for what is missing: no branch decreases self.counts[key], and nothing here or anywhere in the file has a notion of elapsed time. A count is a lifetime total. That absence is not an oversight; it is the definition of LFU, and it is the aha.

5.8 LFUCache.put — a newcomer is worth 1

lru_cache.py · lines 144–159
        if len(self.values) >= self.capacity:
            bucket = self.buckets[self.min_count]
            victim = next(iter(bucket))
            del bucket[victim]
            if not bucket:
                del self.buckets[self.min_count]
            del self.values[victim]
            del self.counts[victim]
            self.evictions += 1
        self.values[key] = value
        self.counts[key] = 1
        self.buckets.setdefault(1, {})[key] = None
        # A brand-new key has count 1, so the minimum is 1 again. Forgetting
        # this line is the classic LFU bug: min_count drifts upward and the
        # cache evicts a frequently used key while a fresh one sits at 1.
        self.min_count = 1

self.counts[key] = 1 and self.min_count = 1 are the two most important assignments in the file, and between them they produce §6.2 entirely.

Every arriving key is stamped with the lowest possible score and dropped into the lowest bucket — which is also, by definition, the bucket eviction reads from. A new key is admitted directly into the death row it will be taken from on the very next miss. In a stable workload that is exactly right: it is how LFU refuses to let a one-hit wonder displace something valuable. What it costs is §6.

The self.min_count = 1 line is not optional bookkeeping. Delete it and the cache doesn't degrade, it crashesmin_count keeps pointing at a bucket that was deleted:

min_count = 1 on insert OMITTED CRASHES: KeyError: 0

Load-bearing, loudly, which is the good kind. Compare with §5.7's guard, which is equally load-bearing and silent — a nice illustration that "does it crash?" is uncorrelated with "does it matter?"


6. The demo, and what it proves

demo.py runs three traces. All randomness is a seeded random.Random, so these numbers reproduce exactly.

6.1 The expected result: LFU wins

Trace 1 is stationary Zipf — key i drawn with weight 1/i, 1000 keys, 20,000 requests, capacity 20. Popularity never changes.

python3 demo.py
================================================================== 1. STATIONARY ZIPF (s=1.0, 1000 keys, 20000 requests, capacity 20) ================================================================== LRU hit rate 31.95% (6389 hits / 20000) LFU hit rate 43.11% (8622 hits / 20000) delta +11.16pp -- LFU wins, as expected

Exactly the textbook outcome, and worth deriving so it doesn't feel like magic. Under a Zipf-1 distribution over 1000 keys, the 20 most popular keys account for (1 + 1/2 + … + 1/20) / (1 + 1/2 + … + 1/1000) = 3.5977 / 7.4855 = 48.1% of all requests. A cache of 20 that simply identifies and holds those 20 keys forever gets ~48%; LFU gets 43.11%, close to that ceiling and short of it only by the warm-up it spends discovering which keys they are.

LRU can't hold them, because it isn't trying to. Every request for an unpopular key — and 51.9% of requests are for unpopular keys — is promoted to the front and pushes a genuinely popular key one step toward the tail. LRU spends most of its 20 slots on keys it will never see again.

If you stop reading here, you have the conventional wisdom: frequency is more information than recency, so use it.

6.2 The flip

Trace 2 has the same shape throughout: 90% of requests to a 20-key hot set, 10% spread uniformly over all 1000 keys. The only thing that happens is that after 20,000 requests, the hot set is replaced by 20 different keys. Same cache objects, same capacity, same code.

================================================================== 2. THE POPULAR SET SHIFTS ONCE (hot A -> hot B, same 90/10 shape) ================================================================== phase 1 phase 2 holds dead-hot holds new-hot ph.2 evicts LRU 73.94% 74.32% 0/20 19/20 5136 LFU 87.21% 4.16% 19/20 1/20 19169 hit rate per 2000-request window of phase 2: LRU 70.5 74.2 74.8 74.7 75.3 77.1 72.7 73.0 76.0 74.8 LFU 3.5 4.0 4.0 4.7 4.2 4.7 3.5 3.5 4.9 4.6

In phase 1, LFU beats LRU by 13.3 points — the §6.1 result again. In phase 2, LFU is beaten by 70 points, and the per-window row shows it isn't a transient: ten consecutive windows, no trend, no recovery. LRU's row doesn't move at all; it re-learns the new hot set inside the first 2000-request window and carries on.

The final columns say why. LFU is still holding 19 of the 20 keys that were hot in phase 1 and are never requested again. LRU is holding 19 of the 20 keys that are hot now.

6.3 The derivation: 19 frozen slots and one revolving door

This is the part worth carrying away, and the demo prints the arithmetic rather than asking you to trust it.

what LFU holds the instant phase 2 begins (all 20 residents): k608=1 k2 =851 k15 =878 k7 =880 k5 =882 k10 =890 k9 =892 k19 =897 k0 =898 k4 =901 k13 =903 k11 =905 k1 =915 k16 =915 k6 =923 k12 =929 k8 =932 k3 =934 k14 =934 k18 =938 -> 19 keys at counts 851..938, and 1 slot at count 1

Now do the subtraction. A newly hot key arrives, misses, and is admitted at count 1 (§5.8). The weakest incumbent has 851. For the newcomer to outrank anybody, it needs 850 more hits while resident. But min_count is now 1, its bucket contains exactly one key — itself — so the very next miss evicts it. With 90% of traffic spread over 20 hot keys, the probability that the next request is a repeat of the key just admitted is 0.9 / 20 = 4.5%; the other 95.5% of the time, a different key misses and the newcomer is gone.

The demo hand-feeds two requests to make it concrete:

the revolving door, step by step (same cache, hand-fed): request k500 (new hot key) -> miss, admitted at count 1; evicted k608 request k501 (new hot key) -> miss, admitted at count 1; evicted k500 k500 needed 850 more hits to outrank the weakest resident.

So the cache is not 20 slots. It is 19 frozen slots and one revolving door, and the demo confirms that reading against the full 20,000-request phase:

...and after all 20000 requests of phase 2 and 19169 evictions: 19 of those 19 keys are still resident, now at counts 853..939. Nothing evicted them. Every one of those evictions hit one slot.

Nineteen thousand one hundred and sixty-nine evictions, and not one of them touched the nineteen dead keys — their counts crept up by only ~2 apiece, from the trickle of cold traffic. All 19,169 went through a single slot; 869 distinct keys were resident at some point during phase 2, and 850 of them came and went through that one door.

That also derives the 4.16%. Categorising phase 2's 831 hits by where they came from:

total phase-2 hits: 831 of 20000 = 4.16% dead-hot key (cold traffic) 35 new-hot key (revolving slot) 796 other cold key 0

796 of the 831 are the revolving door catching a repeat — 796/18070 requests to the new hot set = 4.4%, against the 4.5% predicted above. The other 35 are cold requests that happened to land on one of the 19 fossils. LFU's entire phase-2 hit rate is the probability that a key is requested twice in a row.

Two more runs to close it off. The collapse is caused by the shift and by nothing else — replay phase 2 with the same hot set and LFU is fine:

LRU, phase 2 keeps the SAME hot set: phase1= 73.94% phase2= 74.39% LFU, phase 2 keeps the SAME hot set: phase1= 87.21% phase2= 87.30%

And "never recovers" means never. Extending phase 2 to 400,000 requests — twenty times the whole trace:

LFU requests 0-100000 after the shift: 4.33% LFU requests 100000-200000 after the shift: 4.36% LFU requests 200000-300000 after the shift: 4.20% LFU requests 300000-400000 after the shift: 4.24% after 400k post-shift requests: holds 19/20 dead-hot, 0/20 new-hot

Flat. There is no recovery point to identify, because there is no path to recovery: escaping the revolving door requires 850 consecutive hits on one key without an intervening miss, and its probability is 0.045850.

6.4 The same mechanism, visible before the shift

Look again at the phase-1 numbers. LFU got 87.21%, but the ceiling for any 20-slot cache on that trace is 90.18% (18,056 of 20,000 requests go to the hot set, minus 20 compulsory first-touch misses). The 3-point gap looks like warm-up. It isn't:

phase 1 already shows the mechanism, per hot key: LFU: 19 of the 20 hot keys missed 1-2 times all phase; k17 missed 593 of its 857 requests, alone. LRU: every hot key missed 151-175 times -- the same tax, spread out.

Nineteen hot keys each missed once. The twentieth, k17, lost the early race for the last slot and spent the entire phase in the revolving door — 593 misses on a key requested 857 times, in a cache that had room for it and never let it back in. And 593/20,000 = 2.97 points, which is the gap: 90.18% − 2.97% = 87.21%. The whole shortfall is one key.

That is the same failure as §6.3, at 1/20th scale, happening while LFU is winning. It also gives the cleanest one-line statement of the difference:

One property, seen from two sides LRU pays a small tax on every hot key. LFU pays nothing on almost all of them and everything on the ones that arrived late.

(LRU's phase-1 misses split as 3273 on the 20 hot keys — 163.7 each — and 1938 on cold keys. That's the tax: every cold request evicts something, and with recency ordering the something is whichever hot key has been quietest.)

6.5 The boundary condition: where LRU is the one that fails

The effect above vanishes — and reverses — on a workload with no popularity structure at all. Part 3 of the demo loops over a working set slightly larger than the cache:

================================================================== 3. BOUNDARY: A SEQUENTIAL SCAN, WHERE LRU IS THE LOSER ================================================================== 100 keys, capacity 100: LRU 95.00% random(seed=1) 95.00% random(seed=2) 95.00% 101 keys, capacity 100: LRU 0.00% random(seed=1) 92.80% random(seed=2) 93.05%
Where the effect vanishes, and reverses One extra key takes LRU from 95.00% to 0.00%. With 100 keys everything fits and every policy is perfect. With 101, LRU evicts key 1 to admit key 101 — key 1 is genuinely the least recently used — and then the loop comes back around and asks for key 1, which evicts key 2, which is about to be asked for. LRU is precisely wrong: it discards the key it is about to need, every single time, forever. Zero hits in 2000 requests. (LFU does the same thing here, for its own reason: every key has an equal count, so the tie-break picks the oldest arrival, which is the next one needed.)

Random replacement — a coin flip, no state, no linked list, no counters — gets 92.80%, because it is merely usually wrong instead of always wrong. That's the honest boundary on everything above: recency and frequency are both bets on structure in the request stream, and a scan has none. Redis documents exactly this, recommending allkeys-random "when you expect all keys to be accessed with roughly equal frequency… when your app reads data items in a repeating cycle."

6.6 The numbers are pinned by tests

python3 test_lru_cache.py
PASS test_lru_evicts_the_tail_not_the_oldest_insert PASS test_lru_get_reorders_the_list PASS test_lru_links_stay_consistent_under_churn PASS test_lfu_keeps_the_frequently_used_key PASS test_lfu_breaks_count_ties_by_arrival_order PASS test_lfu_min_count_returns_to_one_after_an_insert PASS test_lfu_wins_on_a_stationary_zipf_trace PASS test_the_shift_collapses_lfu_and_not_lru PASS test_lfu_holds_nineteen_frozen_slots_through_the_shift PASS test_a_sequential_scan_takes_lru_to_zero PASS test_neither_cache_ever_exceeds_its_capacity All 11 tests PASSED

test_the_shift_collapses_lfu_and_not_lru asserts the four exact hit counts behind §6.2, and test_lfu_holds_nineteen_frozen_slots_through_the_shift asserts the 19 frozen keys, the 19,169 evictions, and that none of the frozen keys was among them. If someone edits the policy, this page fails loudly instead of rotting quietly.


7. Design decisions and roads not taken

7.1 Why a hand-built linked list rather than OrderedDict

Python ships an LRU cache. OrderedDict.move_to_end(key) plus popitem(last=False) is a five-line LRUCache, and functools.lru_cache is already in the stdlib and faster than anything here. Using either would have deleted the toy: the linked list is the mechanism this toy exists to show. move_to_end is a doubly linked list splice with a nicer name — the one in CPython's OrderedDict — and hiding it behind a method call hides precisely the thing worth understanding, which is that get performs a write of four pointers and that this is where LRU's cost lives (§5.4).

7.2 Why LFU has buckets instead of min(counts)

A one-line eviction — min(self.counts, key=self.counts.get) — would be easier to read and behaviourally identical. It's O(n) per eviction, and in the demo LFU performs 19,169 evictions against 20 residents, so the toy would still finish instantly. It's out for two reasons. First, it would make LFU look more expensive than LRU as a matter of principle, when in fact O(1) LFU is a known and simple structure. Second — and this is the real reason — the buckets are what make the arithmetic visible. min_count and the count buckets are the state the §6.3 derivation reasons over; with a min() call the collapse becomes something you're told rather than something you can read off a dump of the residents.

7.3 Why no aging or decay in LFU, when everyone knows to add it

Because its absence is the toy. Every production LFU has a decay term. Redis exposes it as lfu-decay-time (default: halve the counter every minute) and antirez's own writeup of it says why in one sentence — "a key with a high score needs to see its score reduced over time if nobody keeps accessing it." Adding decay here would move the whole point of the page into a tunable constant.

That said, the cheapest known fix is one line, and not running it would have been a missed opportunity. Admit newcomers at min_count rather than at 1 — a newcomer inherits the score of the key it replaced, which is the trick used by LFU-with-dynamic-aging:

LFUCache (as written) phase2= 4.16% dead=19/20 new= 1/20 newcomer admitted at min_count, not at 1 phase2= 17.47% dead= 0/20 new=19/20 per-window, phase 2: 3.5 4.0 4.0 4.7 4.2 4.7 3.5 6.0 53.4 86.7

It works, eventually — the last window reaches 86.7% and the cache ends up holding the right keys. But look at the first seven windows: identical to broken LFU. The fix takes ~14,000 requests to bite, against LRU's ~2,000, and the phase-2 average is still 57 points behind LRU. This is why the real answer isn't a patch to LFU (§7.4).

7.4 Why not ARC or W-TinyLFU, which solve exactly this

Because they're the answer to this page, and the page has to pose the question first. Both exist precisely because of the failure in §6:

Both are 10× this toy's budget. Naming them is more useful than half-building one: if you have measured the §6.2 shape in production, those are the two things to reach for.

7.5 Why the values are all 1

Every put in the demo stores the integer 1. The toy is about which key survives, and a realistic value would add serialization, sizing, and cost weighting without changing a single eviction decision. It also removes an ambiguity: get returns None on a miss, and if values could be None the read-through pattern in demo.replay would be wrong.

7.6 Why two classes and no shared base

They share no code. Their only common ground is get/put, and an abstract base class would imply a family resemblance that doesn't exist — one is a linked list, the other is three dicts. RandomCache in demo.py deliberately lives outside lru_cache.py for the same reason: it's a control for §6.5, not a third policy the toy is teaching.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Every answer is derivable from the source, and every one below was verified by running it.

Question 1

LRUCache(2). You put("a"), put("b"), get("a"), put("c"). Which key is gone, and what does keys() return?

Answer

"b" is gone; keys() is ['c', 'a'].

"a" was inserted first, so an insertion-ordered cache would evict it. But get("a") unlinked it and pushed it to the front (§5.4), leaving "b" at self.tail.prev — and put evicts whatever the tail points at, without looking at anything else (§5.5). This is test_lru_evicts_the_tail_not_the_oldest_insert.

Question 2

LFUCache(2). You put("a"), then get("a") twice, then put("b"), then put("c"). What is resident, and what are the counts?

Answer

['c', 'a'], with counts {'a': 3, 'c': 1}.

"a" climbs to count 3 and min_count follows it up to 3. Then put("b") inserts at count 1 and — critically — resets min_count to 1 (§5.8). So put("c") reads buckets[1], finds {"b"}, and evicts "b" after a single request. Delete the self.min_count = 1 line and this raises KeyError instead. keys() lists lowest count first, which is why "c" comes first. This is test_lfu_keeps_the_frequently_used_key.

Question 3

A key is hot for a while and then never requested again. How long does LFU keep it — and roughly how many requests does a newly hot key need before it displaces one?

Answer

Forever, and there is no number of requests that works.

Nothing in _bump or put ever decreases a count (§5.7), so a key that reached 851 is at 851 permanently. A newcomer enters at 1 and is the sole occupant of buckets[1], so the next miss evicts it (§5.8). To reach 851 it would need 850 hits with no intervening miss on any other key — probability 0.045850. Measured: 400,000 requests after the shift, LFU still holds 19/20 dead keys and 0/20 new ones, hit rate flat at 4.2–4.4%.

The honest version of the answer is that the question is malformed. It's not that new keys need many hits; it's that they get exactly one request to prove themselves, and one is never enough.

Question 4

Does giving a cache more memory ever produce fewer hits?

Answer

Not for these two. For FIFO, yes — that's Bélády's anomaly. On the classic reference string 1 2 3 4 1 2 5 1 2 3 4 5:

LRU cap 3: 2 hits / 10 misses cap 4: 4 hits / 8 misses LFU cap 3: 2 hits / 10 misses cap 4: 4 hits / 8 misses FIFO (LRU without promote-on-get) cap 3: 3 hits / 9 misses cap 4: 2 hits / 10 misses

FIFO gets worse with a bigger cache. LRU and LFU are stack algorithms: the contents at capacity n are always a subset of the contents at n+1, so extra capacity can't hurt. The FIFO row is LRUCache with the two promote lines deleted from get (§5.4) — the same two lines that were worth 4.6 points on the Zipf trace also buy you the guarantee that adding RAM never backfires.

Question 5

Your service has an LRU cache with a healthy hit rate. A nightly job starts doing a full table scan through the same cache. Predict the morning graph, and say which line in lru_cache.py is responsible.

Answer

The hit rate goes to roughly zero for the duration of the scan and stays low afterwards while the working set is re-learned. §6.5 is that experiment: 101 keys through a 100-slot LRU gives 0.00%, and random replacement on the identical trace gives 92.80%.

The responsible line is self._push_front(node) in put (lru_cache.py line 85): every scanned key, requested exactly once ever, is inserted at the most recently used position, so it must traverse the entire list before becoming evictable — pushing out the real working set on the way. A cache with an admission policy (§8) or a scan-resistant one like ARC solves this by not letting a first-time key straight into the protected region.

Question 6

From §7.3, admitting newcomers at min_count instead of 1 lifts phase 2 from 4.16% to 17.47%. Should you ship it?

Answer

Only if you'd also be happy with LRU, which is simpler and still four times better here.

The per-window row shows why the average is misleading: 3.5 4.0 4.0 4.7 4.2 4.7 3.5 6.0 53.4 86.7. Seven of the ten windows are indistinguishable from unfixed LFU; the 17.47% average is one good window diluted by seven bad ones. The fix ends in the right place (19/20 new hot keys resident) but takes ~14,000 requests to get there, against LRU's ~2,000.

That gap is the argument for ARC and W-TinyLFU (§7.4) over patched LFU: the problem isn't the value a newcomer is admitted at, it's that a pure frequency ordering has no way to notice that the world changed. Fixes that stay inside the ordering can only ever change how fast it fails.


10. Further reading

Every link below was fetched and confirmed live when this was written.