{"id":1935,"date":"2024-10-15T12:25:04","date_gmt":"2024-10-15T12:25:04","guid":{"rendered":"https:\/\/algocademy.com\/blog\/cache-algorithms-fifo-vs-lru-vs-lfu-a-comprehensive-guide\/"},"modified":"2026-10-15T10:34:48","modified_gmt":"2026-10-15T10:34:48","slug":"cache-algorithms-fifo-vs-lru-vs-lfu-a-comprehensive-guide","status":"publish","type":"post","link":"https:\/\/algocademy.com\/blog\/cache-algorithms-fifo-vs-lru-vs-lfu-a-comprehensive-guide\/","title":{"rendered":"Cache Eviction: FIFO vs LRU vs LFU Compared, With Python Code"},"content":{"rendered":"<p>When a cache is full, an eviction policy decides which entry to throw out. <strong>FIFO<\/strong> evicts the entry that was inserted first, <strong>LRU<\/strong> evicts the one not used for the longest time, and <strong>LFU<\/strong> evicts the one used the fewest times. LRU is the default choice in most systems, LFU wins when some keys are popular for a long time, and FIFO is mostly a baseline or a fit for simple, time-ordered data.<\/p>\n<p>This post shows how each policy works, gives a tested Python implementation of each (the LRU and LFU ones run in O(1) per operation, which is what interviewers ask for), and compares them on three small access patterns so you can see where each one wins and loses.<\/p>\n<h2>The cache interface used below<\/h2>\n<p>All three classes have the same interface, the one from the well-known LRU Cache and LFU Cache interview problems: a fixed <code>capacity<\/code>, <code>get(key)<\/code> returning the value or <code>-1<\/code>, and <code>put(key, value)<\/code> which inserts or updates and evicts one entry if the cache is over capacity. A cache lookup that finds the key is a <em>hit<\/em>, one that does not is a <em>miss<\/em>. The goal of a policy is to maximize the hit ratio: hits divided by total lookups.<\/p>\n<h2>FIFO: evict the oldest insertion<\/h2>\n<p>FIFO keeps entries in insertion order and removes the front one when space is needed. Reading a key does not change anything, so a key that is read constantly is still evicted as soon as it becomes the oldest.<\/p>\n<pre><code class=\"language-python\">from collections import OrderedDict\n\n\nclass FIFOCache:\n    def __init__(self, capacity):\n        self.capacity = capacity\n        self.data = OrderedDict()  # insertion order = eviction order\n\n    def get(self, key):\n        return self.data.get(key, -1)  # reads do not change the order\n\n    def put(self, key, value):\n        if self.capacity == 0:\n            return\n        if key not in self.data and len(self.data) &gt;= self.capacity:\n            self.data.popitem(last=False)  # drop the oldest insertion\n        self.data[key] = value  # updating an existing key keeps its place\n<\/code><\/pre>\n<p>Both operations are O(1). The code is short and there is no bookkeeping on reads, which is the whole appeal. The cost is that FIFO ignores usage completely.<\/p>\n<p>A common mistake in hand-rolled versions is using a Python list as the queue and calling <code>pop(0)<\/code>, which is O(n). <code>OrderedDict<\/code> (or <code>collections.deque<\/code> plus a dictionary) avoids that.<\/p>\n<h2>LRU: evict what has not been used for the longest time<\/h2>\n<p>LRU bets on <em>temporal locality<\/em>: something used recently is likely to be used again soon. Every <code>get<\/code> or <code>put<\/code> moves the key to the &#8220;most recent&#8221; end; eviction takes from the &#8220;least recent&#8221; end.<\/p>\n<p>The classic O(1) design is a hash map pointing into a doubly linked list. The hash map finds a node in O(1); the linked list lets you unlink it and move it to the front in O(1). If you want to practice the pieces, the <a href=\"https:\/\/algocademy.com\/link\/?lang=py&amp;problem=hash-map&amp;solution=1\">Hash Map<\/a> and <a href=\"https:\/\/algocademy.com\/link\/?lang=py&amp;problem=linked-lists&amp;solution=0\">Linked Lists<\/a> lessons cover them. Python&#8217;s <code>OrderedDict<\/code> is exactly that combination, so in Python (and in an interview, if the interviewer allows it) the implementation is short:<\/p>\n<pre><code class=\"language-python\">class LRUCache:\n    def __init__(self, capacity):\n        self.capacity = capacity\n        self.data = OrderedDict()  # least recently used first\n\n    def get(self, key):\n        if key not in self.data:\n            return -1\n        self.data.move_to_end(key)\n        return self.data[key]\n\n    def put(self, key, value):\n        if self.capacity == 0:\n            return\n        if key in self.data:\n            self.data.move_to_end(key)\n        self.data[key] = value\n        if len(self.data) &gt; self.capacity:\n            self.data.popitem(last=False)\n<\/code><\/pre>\n<p>If the interviewer asks you to build it without <code>OrderedDict<\/code>, write the hash map plus doubly linked list with two sentinel nodes (a dummy head and tail) so you never handle empty-list special cases. Our <a href=\"https:\/\/algocademy.com\/blog\/understanding-cache-replacement-policies-a-deep-dive-into-lru\/\">deeper post on LRU<\/a> goes through that version.<\/p>\n<p>LRU&#8217;s weak spot is a <em>scan<\/em>: a loop over more distinct keys than the cache can hold, or a one-time pass over a big table, pushes out everything useful. Each key is evicted just before it is needed again, so the hit ratio can fall to zero.<\/p>\n<h2>LFU: evict what has been used the least<\/h2>\n<p>LFU counts accesses per key and evicts the key with the lowest count. When several keys share the lowest count, the usual tie-break is least recently used among them. This makes LFU good at protecting keys that are popular over a long period.<\/p>\n<p>The O(1) design keeps three maps: key to value, key to count, and count to an ordered set of keys with that count. It also tracks <code>min_freq<\/code>, the lowest count currently present. When a key is used its count goes up by one and it moves to the next bucket; if its old bucket was the minimum and is now empty, <code>min_freq<\/code> goes up by one. A newly inserted key always has count 1, so <code>min_freq<\/code> resets to 1.<\/p>\n<pre><code class=\"language-python\">from collections import defaultdict\n\n\nclass LFUCache:\n    def __init__(self, capacity):\n        self.capacity = capacity\n        self.values = {}                          # key -&gt; value\n        self.freq = {}                            # key -&gt; use count\n        self.buckets = defaultdict(OrderedDict)   # count -&gt; keys, oldest first\n        self.min_freq = 0\n\n    def _touch(self, key):\n        f = self.freq[key]\n        del self.buckets[f][key]\n        if not self.buckets[f]:\n            del self.buckets[f]\n            if self.min_freq == f:\n                self.min_freq = f + 1\n        self.freq[key] = f + 1\n        self.buckets[f + 1][key] = None\n\n    def get(self, key):\n        if key not in self.values:\n            return -1\n        self._touch(key)\n        return self.values[key]\n\n    def put(self, key, value):\n        if self.capacity == 0:\n            return\n        if key in self.values:\n            self.values[key] = value\n            self._touch(key)\n            return\n        if len(self.values) &gt;= self.capacity:\n            victim, _ = self.buckets[self.min_freq].popitem(last=False)\n            if not self.buckets[self.min_freq]:\n                del self.buckets[self.min_freq]\n            del self.values[victim]\n            del self.freq[victim]\n        self.values[key] = value\n        self.freq[key] = 1\n        self.buckets[1][key] = None\n        self.min_freq = 1\n<\/code><\/pre>\n<p>A frequent bug is a heap-based LFU that re-heapifies on every access, which is O(n) per operation. Another is forgetting to reset <code>min_freq<\/code> to 1 on insert.<\/p>\n<p>LFU has two known weaknesses. First, <strong>stale popularity<\/strong>: a key that was hot yesterday keeps a high count and stays even though nobody asks for it now, while new keys start at 1 and are the first to be evicted. Second, the extra bookkeeping: a counter per key and more work per access. Real systems usually fix the first problem by decaying the counts over time.<\/p>\n<h2>Same operations, three different victims<\/h2>\n<p>Capacity 2: put 1, put 2, get 1, then put 3. Which key gets evicted?<\/p>\n<pre><code class=\"language-python\">for cls in (FIFOCache, LRUCache, LFUCache):\n    c = cls(2)\n    c.put(1, 1)\n    c.put(2, 2)\n    a = c.get(1)\n    c.put(3, 3)\n    print(cls.__name__, a, c.get(1), c.get(2), c.get(3))\n<\/code><\/pre>\n<p>Output:<\/p>\n<pre><code class=\"language-text\">FIFOCache 1 -1 2 3\nLRUCache 1 1 -1 3\nLFUCache 1 1 -1 3\n<\/code><\/pre>\n<p>FIFO evicts key 1 even though it was just read, because it was inserted first. LRU and LFU evict key 2: it is both less recently and less frequently used than key 1. To separate LRU from LFU you need longer access patterns.<\/p>\n<h2>Hit ratio on three access patterns<\/h2>\n<p>Here is a small simulator: on each key, call <code>get<\/code>; on a miss, <code>put<\/code> the key. Three traces, all with capacity 3:<\/p>\n<pre><code class=\"language-python\">def hit_ratio(cls, capacity, trace):\n    c = cls(capacity)\n    hits = 0\n    for k in trace:\n        if c.get(k) != -1:\n            hits += 1\n        else:\n            c.put(k, k)\n    return hits \/ len(trace)\n\n# Two hot keys used repeatedly, with three never-repeated keys in between\none_offs = []\nn = 100\nfor _ in range(50):\n    one_offs += [0, 1, 0, 1, n, n + 1, n + 2]\n    n += 3\n\nloop = [0, 1, 2, 3] * 25                        # cycle over 4 keys\nshift = [0, 1, 2] * 30 + [10, 11, 12] * 30      # popular keys change halfway\n\nfor name, t in ((\"one-offs\", one_offs), (\"loop\", loop), (\"shift\", shift)):\n    print(name, *(f\"{cls.__name__[:-5]}={hit_ratio(cls, 3, t):.2f}\"\n                  for cls in (FIFOCache, LRUCache, LFUCache)))\n<\/code><\/pre>\n<pre><code class=\"language-text\">one-offs FIFO=0.29 LRU=0.29 LFU=0.57\nloop FIFO=0.00 LRU=0.00 LFU=0.00\nshift FIFO=0.97 LRU=0.97 LFU=0.48\n<\/code><\/pre>\n<table>\n<thead>\n<tr>\n<th>Pattern<\/th>\n<th>What happens<\/th>\n<th>Winner<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Hot keys plus one-off keys<\/td>\n<td>One-off keys flush FIFO and LRU, but LFU keeps the two keys with high counts<\/td>\n<td>LFU<\/td>\n<\/tr>\n<tr>\n<td>Loop larger than the cache<\/td>\n<td>Every key is evicted just before it is used again; all three score zero<\/td>\n<td>None (needs a bigger cache or a different policy)<\/td>\n<\/tr>\n<tr>\n<td>Popularity shifts<\/td>\n<td>LFU keeps the old popular keys because of their high counts and misses the new ones for a long time<\/td>\n<td>FIFO and LRU<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>These traces are tiny and chosen to show the failure modes, so do not read the exact numbers as typical. On real traffic, measure with a trace from your own system.<\/p>\n<h2>Comparison<\/h2>\n<table>\n<thead>\n<tr>\n<th><\/th>\n<th>FIFO<\/th>\n<th>LRU<\/th>\n<th>LFU<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Evicts<\/td>\n<td>Oldest insertion<\/td>\n<td>Longest since last use<\/td>\n<td>Fewest uses (ties: least recent)<\/td>\n<\/tr>\n<tr>\n<td>Tracks on a read<\/td>\n<td>Nothing<\/td>\n<td>Recency order<\/td>\n<td>A counter per key<\/td>\n<\/tr>\n<tr>\n<td>Time per operation<\/td>\n<td>O(1)<\/td>\n<td>O(1)<\/td>\n<td>O(1) with the bucket design<\/td>\n<\/tr>\n<tr>\n<td>Extra memory per entry<\/td>\n<td>Small<\/td>\n<td>List links<\/td>\n<td>Counter plus bucket membership<\/td>\n<\/tr>\n<tr>\n<td>Strong when<\/td>\n<td>Data ages out in insertion order<\/td>\n<td>Recent items are reused<\/td>\n<td>Popularity is stable<\/td>\n<\/tr>\n<tr>\n<td>Weak when<\/td>\n<td>A hot item is old<\/td>\n<td>Scans and loops<\/td>\n<td>Popularity shifts, one-time bursts of counts<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>What real systems use<\/h2>\n<ul>\n<li><strong>Python:<\/strong> <code>functools.lru_cache<\/code> memoizes a function with an LRU policy; its default <code>maxsize<\/code> is 128, and <code>functools.cache<\/code> is the same thing with no size limit, so it never evicts (<a href=\"https:\/\/docs.python.org\/3\/library\/functools.html\">Python docs<\/a>).<\/li>\n<li><strong>Redis:<\/strong> when <code>maxmemory<\/code> is reached, the <code>maxmemory-policy<\/code> setting chooses the behavior. Options include <code>allkeys-lru<\/code>, <code>allkeys-lfu<\/code>, <code>allkeys-random<\/code> and the <code>volatile-*<\/code> variants that only touch keys with an expiry. Redis&#8217;s LRU and LFU are approximations: it samples a few keys and evicts the best candidate among them rather than tracking exact order. LFU is available since Redis 4.0 and uses a small probabilistic counter that decays over time, which addresses the stale-popularity problem. The Redis docs call <code>allkeys-lru<\/code> a good default if you have no reason to prefer another policy (<a href=\"https:\/\/redis.io\/docs\/latest\/develop\/reference\/eviction\/\">Redis docs<\/a>, as of October 2026).<\/li>\n<\/ul>\n<p>The pattern is clear: production caches are usually LRU or LFU variants that trade exactness for lower memory and CPU cost, often with extras such as expiry times and scan resistance.<\/p>\n<h2>How to choose<\/h2>\n<ol>\n<li><strong>Start with LRU.<\/strong> It matches most real access patterns and costs little.<\/li>\n<li><strong>Consider LFU<\/strong> if a stable set of popular keys is being pushed out by bursts of one-time keys, and you can decay counts so old favorites fade.<\/li>\n<li><strong>FIFO is fine<\/strong> when entries naturally expire in the order they were added, or when you need the cheapest possible policy and misses are cheap.<\/li>\n<li><strong>Measure.<\/strong> Log hits and misses, replay a real trace against each policy, and compare. A policy that wins on paper can lose on your traffic.<\/li>\n<\/ol>\n<h2>In interviews<\/h2>\n<p>&#8220;Design an LRU cache&#8221; is a standard question, and &#8220;LFU cache&#8221; is its harder sibling. Expect to be asked for O(1) <code>get<\/code> and <code>put<\/code>, and to explain why a hash map alone or a list alone cannot do it. Practice writing the doubly linked list version once by hand, then know the <code>OrderedDict<\/code> shortcut. The <a href=\"https:\/\/app.algocademy.com\/\">AlgoCademy app<\/a> has step-by-step practice for the underlying hash table and linked list problems.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How FIFO, LRU and LFU caches decide what to evict, with tested Python code, hit-rate results on sample access patterns, and when to pick each.<\/p>\n","protected":false},"author":1,"featured_media":1934,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[23],"tags":[],"class_list":["post-1935","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-problem-solving"],"_links":{"self":[{"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/posts\/1935"}],"collection":[{"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/comments?post=1935"}],"version-history":[{"count":1,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/posts\/1935\/revisions"}],"predecessor-version":[{"id":8690,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/posts\/1935\/revisions\/8690"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/media\/1934"}],"wp:attachment":[{"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/media?parent=1935"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/categories?post=1935"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/algocademy.com\/blog\/wp-json\/wp\/v2\/tags?post=1935"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}