A tiny LRU cache, from scratch
Python gives you an LRU cache for free:
from functools import lru_cache
@lru_cache(maxsize=256)
def expensive(x: int) -> int:
...That’s the right answer in production. But “least recently used” is one of those ideas that feels obvious until you try to implement it, so let’s build one — partly for fun, mostly because the implementation is a beautiful use of a data structure you already know.
The trick is the dict
Since Python 3.7, dictionaries preserve insertion order. That’s the whole trick. An LRU cache needs two operations to be fast:
- Look up a key, and mark it as recently used.
- Evict the oldest key when the cache is full.
An ordered dict gives you both. “Mark as recently used” is just delete and re-insert — the key moves to the end. “Evict the oldest” is pop the first key. Both are O(1).
class LRUCache:
def __init__(self, maxsize: int = 256):
self.maxsize = maxsize
self._data: dict = {}
def get(self, key):
if key not in self._data:
raise KeyError(key)
value = self._data.pop(key) # remove...
self._data[key] = value # ...and re-insert at the end
return value
def put(self, key, value) -> None:
if key in self._data:
self._data.pop(key)
elif len(self._data) >= self.maxsize:
oldest = next(iter(self._data))
del self._data[oldest]
self._data[key] = valueTwenty lines. The recently-used order is the dict’s insertion order; we never store it separately, so it can never go stale.
Why the real one is different
CPython’s lru_cache is implemented in C with a doubly linked list threaded through the hash table entries, plus a lock for thread safety. The linked list avoids the pop-and-reinsert dance — it just splices nodes. Same idea, fewer allocations.
The best way to appreciate a standard library function is to write a worse version of it yourself.
If you want homework: add a hits / misses counter and a cache_info() method, then compare your numbers against the real decorator on the same workload. They should match exactly — and if they don’t, finding out why is where the actual learning is.