Two data structures, four pointers, one lock. It fits on a whiteboard, and it still separates people who have written it from people who have memorised it.
"Design an LRU cache" turns up both as a 45-minute coding question in big-tech loops and as a 90-minute machine-coding round at Indian product companies, where the bar is higher: not just a working get and put, but a design. The interviewer wants the eviction rule behind an interface, the cache generic over keys and values, an eviction callback, and a clear answer to "what happens when two threads call get at the same time?"
This lesson builds it the way you should in the round: interfaces first, then the linked list, then LRU as one policy among several, then the cache that ties them together, and a demo that proves the order of evictions.
A bounded cache with O(1) get and put and no OrderedDict: a dict finds the node, a doubly linked list with sentinels keeps the order, and the eviction policy sees only keys through four hooks, so LRU, FIFO or LFU can be swapped. On a capacity-3 cache, put A, B, C, get A, then put D evicts B and an update never evicts. Because get reorders the list, reads take the lock too; the demo runs 8 threads and 16,000 lookups and ends with exactly 100 entries.
How the round runs#
"Build an in-memory cache with a fixed capacity. When it's full, evict the least recently used entry. get and put must be O(1). I'd like to be able to change the eviction policy later. No OrderedDict, no functools.lru_cache. You have 90 minutes, and it has to run."
- 0–10 min — clarify: what counts as "use", what
getreturns on a miss, capacity 0, thread safety - 10–20 min — interfaces:
CacheandEvictionPolicy, agreed before any code - 20–50 min — the doubly linked list with sentinels, then
LRUPolicy - 50–70 min —
BoundedCache: storage + policy + capacity + lock, and the eviction callback - 70–80 min — a demo that prints the order after each step, plus a thread hammer
- 80–90 min — follow-ups: TTL, LFU, size in bytes, sharding the lock
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
Does put on an existing key count as a use? |
Yes, and it must not evict anything. | put updates in place and moves the key to the front |
What does get return on a miss? |
None. |
Return type V | None; no sentinel -1 |
Is get a "use"? |
Yes, that's the point of LRU. | get mutates the order, which matters for locking |
| Capacity 0 or negative? | Reject it at construction. | A ValueError in __init__ |
| Single-threaded? | No, a web server's worker threads share it. | One lock; reads take it too |
| Do we need to know what was evicted? | Yes, to write it back to the database. | An on_evict(key, value) callback |
| TTL, size limits in bytes? | Not now; maybe as a follow-up. | Nothing yet |
Why isn't a hash map alone enough?
A dict gives O(1) lookup. What does LRU need that a dict can't do in O(1), and which structure supplies it?
One strong answer
LRU needs to move any entry to the "most recent" end on every access and remove the oldest on eviction, both in O(1). A dict finds an entry in O(1) but has no cheap way to reorder arbitrary items. A doubly linked list can unlink any node in O(1) if you already hold a pointer to it, and the dict supplies that pointer. Map for finding, list for ordering: neither works alone.
The classes#
%% title: LRU cache: the classes
classDiagram
class Cache {
<<interface>>
+get(key) V
+put(key, value)
+delete(key) bool
}
class EvictionPolicy {
<<interface>>
+on_insert(key)
+on_access(key)
+on_delete(key)
+victim() K
}
class BoundedCache {
-data dict
-lock Lock
+capacity int
+on_evict callback
}
class LRUPolicy {
-nodes dict
-order DoublyLinkedList
}
class FIFOPolicy
class DoublyLinkedList {
-head Node
-tail Node
+push_front(node)
+unlink(node)
+last() Node
}
class Node {
+key K
+prev Node
+next Node
}
Cache <|.. BoundedCache
EvictionPolicy <|.. LRUPolicy
EvictionPolicy <|.. FIFOPolicy
BoundedCache --> EvictionPolicy
LRUPolicy *-- DoublyLinkedList
DoublyLinkedList *-- NodeThe important split: the cache owns the values; the policy only sees keys. BoundedCache stores key → value and enforces capacity. EvictionPolicy is told about every insert, access and delete, and answers one question: "who leaves next?" That is what makes LRU, FIFO and LFU interchangeable.
The interfaces#
Generic over K and V so the same cache holds str → User or int → bytes. Four policy hooks are all any eviction rule needs:
# lru_cache.py, part 1: the interfaces
from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Generic, Hashable, TypeVar
K = TypeVar("K", bound=Hashable)
V = TypeVar("V")
class Cache(ABC, Generic[K, V]):
@abstractmethod
def get(self, key: K) -> V | None: ...
@abstractmethod
def put(self, key: K, value: V) -> None: ...
@abstractmethod
def delete(self, key: K) -> bool: ...
class EvictionPolicy(ABC, Generic[K]):
"""Decides who leaves. It only sees keys; the cache owns the values."""
@abstractmethod
def on_insert(self, key: K) -> None: ...
@abstractmethod
def on_access(self, key: K) -> None: ...
@abstractmethod
def on_delete(self, key: K) -> None: ...
@abstractmethod
def victim(self) -> K: ...The linked list uses sentinels: a dummy head and a dummy tail that are never removed. Every real node always has a real prev and next, so push_front and unlink have no if node is None branches. That is where most hand-written LRU bugs live.
# part 2: the doubly linked list, with sentinels so there are no None checks at the ends
class Node(Generic[K]):
__slots__ = ("key", "prev", "next")
def __init__(self, key: K | None = None) -> None:
self.key = key
self.prev: Node[K] = self
self.next: Node[K] = self
class DoublyLinkedList(Generic[K]):
def __init__(self) -> None:
self.head: Node[K] = Node() # head.next is the most recently used
self.tail: Node[K] = Node() # tail.prev is the least recently used
self.head.next, self.tail.prev = self.tail, self.head
def push_front(self, node: Node[K]) -> None:
node.prev, node.next = self.head, self.head.next
self.head.next.prev = node
self.head.next = node
def unlink(self, node: Node[K]) -> None:
node.prev.next, node.next.prev = node.next, node.prev
def last(self) -> Node[K] | None:
return None if self.tail.prev is self.head else self.tail.prev
def keys(self) -> list[K]:
out: list[K] = []
n = self.head.next
while n is not self.tail:
out.append(n.key) # type: ignore[arg-type] # only sentinels have key None
n = n.next
return outThe patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | EvictionPolicy with LRUPolicy, FIFOPolicy |
"Change the eviction policy later" is in the problem statement. A new rule is a new class; BoundedCache doesn't change. |
| Composition over inheritance | BoundedCache has a policy; LRUPolicy has a list |
No LRUCache(Cache) subclass tree, where every new policy duplicates the storage code. |
| Observer (light) | on_evict callback |
The caller learns what left (to write back, to count, to log) without the cache knowing why. |
| Generics | Cache[K, V] |
One implementation for every key and value type, checked by a type checker. |
Concurrency and edge cases#
The question interviewers love: "get is a read, so it doesn't need a lock, right?" Wrong. In an LRU cache, get moves the node to the front: four pointer writes. Two threads doing that on neighbouring nodes at once can leave a node pointing at a node that is no longer in the list, and the list silently loses entries. Every operation that touches the order takes the lock, reads included.
puton an existing key: update the value and move it to the front; never evict for an update- Capacity 1: every new key evicts the only one; the sentinels make this case work with no special code
deleteof a missing key: returnFalse, don't raise- Eviction callback that is slow or throws: call it after releasing the lock, so it can't stall every other thread or leave the lock held
- Caching
Noneas a value: ourgetcan't tell "missing" from "stored None". Either forbidNonevalues or return a(found, value)pair; say which you chose
One lock serialises everything, which is fine to a few hundred thousand operations a second in CPython. Past that, the follow-up is sharding: N independent caches, each with its own lock, picked by hash(key) % N. Each shard is exact LRU; the whole is approximately LRU, which is what Memcached-style caches settle for.
The code#
LRU as a policy: the dict finds the node, the list keeps the order. FIFOPolicy reuses the same list but ignores accesses, which proves the seam in eight lines.
# part 3: LRU as a policy: a dict to find the node in O(1), a list to keep the order
class LRUPolicy(EvictionPolicy[K]):
def __init__(self) -> None:
self._nodes: dict[K, Node[K]] = {}
self._order: DoublyLinkedList[K] = DoublyLinkedList()
def on_insert(self, key: K) -> None:
node = Node(key)
self._nodes[key] = node
self._order.push_front(node)
def on_access(self, key: K) -> None:
node = self._nodes[key]
self._order.unlink(node)
self._order.push_front(node)
def on_delete(self, key: K) -> None:
self._order.unlink(self._nodes.pop(key))
def victim(self) -> K:
node = self._order.last()
if node is None:
raise LookupError("nothing to evict")
return node.key # type: ignore[return-value]
def order(self) -> list[K]:
return self._order.keys()
class FIFOPolicy(EvictionPolicy[K]):
"""A second policy, to prove the seam: reads don't change who leaves first."""
def __init__(self) -> None:
self._lru = LRUPolicy[K]()
def on_insert(self, key: K) -> None:
self._lru.on_insert(key)
def on_access(self, key: K) -> None:
pass
def on_delete(self, key: K) -> None:
self._lru.on_delete(key)
def victim(self) -> K:
return self._lru.victim()The cache: storage, capacity, the lock, and the callback outside it.
# part 4: the cache: storage + policy + capacity + one lock
class BoundedCache(Cache[K, V]):
def __init__(self, capacity: int, policy: EvictionPolicy[K], on_evict: Callable[[K, V], None] | None = None) -> None:
if capacity < 1:
raise ValueError("capacity must be at least 1")
self.capacity = capacity
self.policy = policy
self.on_evict = on_evict
self._data: dict[K, V] = {}
self._lock = threading.Lock() # get() reorders the list, so even reads take it
self.hits = self.misses = 0
def get(self, key: K) -> V | None:
with self._lock:
if key not in self._data:
self.misses += 1
return None
self.hits += 1
self.policy.on_access(key)
return self._data[key]
def put(self, key: K, value: V) -> None:
evicted: tuple[K, V] | None = None
with self._lock:
if key in self._data: # update: no eviction, but it counts as a use
self._data[key] = value
self.policy.on_access(key)
return
if len(self._data) >= self.capacity:
old = self.policy.victim()
self.policy.on_delete(old)
evicted = (old, self._data.pop(old))
self._data[key] = value
self.policy.on_insert(key)
if evicted and self.on_evict:
self.on_evict(*evicted) # outside the lock: a slow listener must not stall the cache
def delete(self, key: K) -> bool:
with self._lock:
if key not in self._data:
return False
del self._data[key]
self.policy.on_delete(key)
return True
def __len__(self) -> int:
return len(self._data)And the demo, in the order the interviewer will check it:
# part 5: the interviewer's test sequence, then a thread hammer
if __name__ == "__main__":
lru: LRUPolicy[str] = LRUPolicy()
cache: BoundedCache[str, str] = BoundedCache(3, lru, on_evict=lambda k, v: print("evicted", k))
cache.put("A", "Aarav")
cache.put("B", "Bhavna")
cache.put("C", "Chetan")
print(lru.order()) # ['C', 'B', 'A']
cache.get("A") # A is now the most recent
cache.put("D", "Diya") # evicted B
print(lru.order()) # ['D', 'A', 'C']
cache.put("C", "Chetan v2") # update, no eviction
print(lru.order(), cache.get("B")) # ['C', 'D', 'A'] None
fifo: BoundedCache[str, int] = BoundedCache(2, FIFOPolicy())
fifo.put("x", 1)
fifo.put("y", 2)
fifo.get("x")
fifo.put("z", 3) # FIFO ignores the read: x leaves anyway
print(fifo.get("x"), fifo.get("y"), fifo.get("z")) # None 2 3
shared: BoundedCache[int, int] = BoundedCache(100, LRUPolicy())
def worker(seed: int) -> None:
for i in range(2000):
k = (seed * 7919 + i * 31) % 300
if shared.get(k) is None:
shared.put(k, k * k)
threads = [threading.Thread(target=worker, args=(s,)) for s in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print(len(shared), "entries,", shared.hits + shared.misses, "lookups") # 100 entries, 16000 lookups['C', 'B', 'A']
evicted B
['D', 'A', 'C']
['C', 'D', 'A'] None
None 2 3
100 entries, 16000 lookupsRead the second and third lines carefully: after get("A"), A is no longer the oldest, so inserting D evicts B. That one line is what the interviewer is waiting for.
Extending it: the follow-ups#
- "Add a TTL per entry." Store
(value, expires_at)and inject aClock. Expire lazily:gettreats an expired entry as a miss and deletes it. For memory, add a periodic sweep, or keep a min-heap of expiry times. - "Implement LFU." A new
EvictionPolicy:key → frequency, andfrequency → ordered set of keys, plus the current minimum frequency.on_accessmoves a key from bucketftof+1;victimis the oldest key in the minimum bucket. All O(1), and the cache is unchanged. - "Limit by bytes, not by count." Give
putasize(or asizerfunction), keep a running total, and evict in a loop until the new entry fits. Reject entries bigger than the whole cache. - "Make it faster under 64 threads." Shard into N caches by key hash, a lock each. Or use a read-mostly design that records accesses in a buffer and applies them in batches, which is how Caffeine (Java) does it.
- "Write-back to the database on eviction." That is
on_evict. Run it on a background worker if it's slow, and handle the failure: an evicted dirty entry that fails to write must be retried, not dropped.
Common mistakes#
- A list and
list.remove(key)to move a key to the front: O(n), the one thing the problem forbids - Singly linked list: removing a node needs its predecessor, which you don't have in O(1)
- No sentinels, and then bugs in the "first node", "last node" and "only node" cases
- Evicting on update:
putof an existing key at capacity throws out a different, innocent key - No lock on
get, because "it's a read" - LRU hard-coded into the cache, so "make the policy pluggable" means a rewrite
LRU cache
Q1/4 Capacity 3. put A, put B, put C, get A, put D. Which key is evicted?
Answer & explanation
Answer: B
After get(A) the order from newest to oldest is A, C, B. Inserting D evicts the least recently used: B. FIFO would have evicted A.
Q2/4 Why does the LRU policy need a doubly linked list rather than a singly linked one?
Answer & explanation
Answer: B
To remove a node from the middle you rewire its predecessor's next pointer. With only next pointers, finding the predecessor is O(n). The dict gives you the node; prev gives you its neighbour.
Q3/4 A teammate removes the lock from get() because "reads are safe". What can go wrong?
Answer & explanation
Answer: C
In LRU every get moves a node to the front: several pointer writes. Two threads doing that at once can leave the list broken, with nodes that the dict still points at but the list no longer contains.
Q4/4 The interviewer asks for LFU instead of LRU. What should change in this design?
Answer & explanation
Answer: A
The cache only asks the policy "who leaves next?" LFU is a different answer to that question, so it is a new policy class.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.