cv.cm

Coding · Data structures

LRU cache explained: how it works, code and complexity

6 min read

Short answer

An LRU (least recently used) cache holds a fixed number of items and, when it is full, evicts the item that was used longest ago. The classic design combines a hash map, which finds any key in O(1), with a doubly linked list ordered by recent use, which moves or removes any entry in O(1). In Python, collections.OrderedDict makes an LRU cache a few lines long, and functools.lru_cache adds one to any function.

What problem an LRU cache solves

A cache keeps the results of slow operations, such as database queries, web requests or expensive computations, so repeated requests are answered quickly. Memory is limited, so the cache must decide what to throw away when it is full. LRU bets that data used recently will be used again soon, and data not touched for a long time probably will not. This bet works well for many real workloads, which is why LRU and its approximations appear in CPU caches, operating system page caches, databases, web servers and CDNs.

The idea: two structures working together

We need two things to be fast: finding a key, and knowing which key is the least recently used. A hash map alone has no order. A list alone is slow to search. Together they do both:

PartResponsibilityWhy it is O(1)
Hash mapmaps each key to its node in the listaverage-case hash lookup
Doubly linked listkeeps nodes ordered from most to least recently usedunlinking a node with prev and next pointers changes four references
Head sidethe most recently used entrynew and accessed entries are moved here
Tail sidethe least recently used entryevict from here when the cache is full

On get(key), look up the node in the map, move it to the front, and return its value. On put(key, value), update or create the node at the front; if the cache is now over capacity, remove the node at the back and delete its key from the map.

Worked example with capacity 2

OperationResultOrder after (most recent first)Note
put(1, "a")-1
put(2, "b")-2, 1cache is full
get(1)"a"1, 21 becomes most recent
put(3, "c")-3, 1evicts 2, the least recently used
get(2)miss3, 12 was evicted
put(1, "A")-1, 3update moves 1 to the front
put(4, "d")-4, 1evicts 3

Python implementation from scratch

Sentinel head and tail nodes remove the special cases for an empty list: every real node always has a node before and after it.

class Node:
    __slots__ = ("key", "value", "prev", "next")

    def __init__(self, key=None, value=None):
        self.key, self.value = key, value
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}
        self.head, self.tail = Node(), Node()   # sentinels
        self.head.next, self.tail.prev = self.tail, self.head

    def _unlink(self, node):
        node.prev.next, node.next.prev = node.next, node.prev

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

    def get(self, key):
        node = self.map.get(key)
        if node is None:
            return None
        self._unlink(node)
        self._push_front(node)
        return node.value

    def put(self, key, value):
        if key in self.map:
            self._unlink(self.map[key])
        node = self.map[key] = Node(key, value)
        self._push_front(node)
        if len(self.map) > self.capacity:
            lru = self.tail.prev
            self._unlink(lru)
            del self.map[lru.key]
cache = LRUCache(2)
cache.put(1, "a")
cache.put(2, "b")
print(cache.get(1))   # a
cache.put(3, "c")     # evicts key 2
print(cache.get(2))   # None
cache.put(1, "A")
cache.put(4, "d")     # evicts key 3
print(cache.get(3), cache.get(1), cache.get(4))  # None A d

The short version with OrderedDict

collections.OrderedDict is itself a hash map combined with a doubly linked list, and move_to_end and popitem(last=False) are O(1). That makes it the idiomatic choice in Python when you need an LRU cache as an object.

from collections import OrderedDict

class LRU:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return None
        self.data.move_to_end(key)          # mark as most recent
        return self.data[key]

    def put(self, key, value):
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)   # drop least recent

c = LRU(2)
c.put("x", 1)
c.put("y", 2)
c.get("x")
c.put("z", 3)
print(list(c.data))  # ['x', 'z']

Caching function results with functools.lru_cache

To cache the return values of a function, use the functools.lru_cache decorator. Arguments must be hashable, because they become dictionary keys. cache_info() shows hits, misses and the current size.

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(80))           # 23416728348467685
print(fib.cache_info())
# CacheInfo(hits=78, misses=81, maxsize=128, currsize=81)

maxsize=None gives an unbounded cache, which is what functools.cache (Python 3.9 and later) does. Call fib.cache_clear() to empty it.

Time and space complexity

OperationTimeReason
getO(1) averagehash lookup plus unlink and relink of one node
put (insert or update)O(1) averagehash insert plus a constant number of pointer changes
EvictO(1)the least recently used node is always next to the tail sentinel
SpaceO(capacity)one map entry and one list node per cached item

Variants, mistakes and when to use LRU

  • LFU (least frequently used) evicts the item with the fewest accesses; it protects popular items from one-off scans but is more complex.
  • Approximate LRU: systems such as Redis sample a few keys and evict the oldest among them instead of keeping an exact list, which saves memory.
  • TTL caches expire entries after a fixed time, often combined with a size limit.
  • Forgetting to move an entry to the front on get, or on put of an existing key, turns LRU into FIFO.
  • Forgetting to delete the evicted key from the hash map leaves dangling entries and leaks memory.
  • Sharing an LRU cache between threads needs a lock around each operation; functools.lru_cache is safe to call from multiple threads.

LRU is a good default when recent use predicts future use. It does poorly when a large one-time scan pushes out frequently used data; workloads like that benefit from LFU or scan-resistant policies. Designing an LRU cache with O(1) operations is also one of the most common coding interview questions, because it tests hash maps and linked lists together.

FAQ

What does LRU stand for?

Least recently used. When the cache is full, it evicts the entry that has gone the longest without being read or written.

Why does an LRU cache need a doubly linked list?

To remove a node from the middle of the list in O(1), you need its previous node. A doubly linked list stores prev on every node; a singly linked list would require an O(n) search.

Can I implement an LRU cache with a Python dict alone?

Since Python 3.7, dict preserves insertion order, so you can delete and re-insert a key to mark it as recent and evict next(iter(d)). OrderedDict states the intent more clearly with move_to_end and popitem(last=False).

What is the difference between lru_cache and cache in functools?

functools.cache is an unbounded cache, equivalent to lru_cache(maxsize=None). lru_cache with a number evicts the least recently used results once it reaches that size.

Is LRU always the best eviction policy?

No. It is simple and works well when recent items are likely to be reused, but large sequential scans can flush useful data. LFU, ARC and other policies handle such workloads better.

All coding tutorials