~learn.

LLD 0211 min+60 XP

Design an LRU Cache.

Design a bounded cache with pluggable eviction, then make it run

6 code

Lesson · 11 min

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.

ExplainerThe whole design: LRU cache

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#

Interviewer

"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."

A good 90 minutes
  • 0–10 min —
    clarify: what counts as "use", what get returns on a miss, capacity 0, thread safety
  • 10–20 min —
    interfaces: Cache and EvictionPolicy, 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramLRU cache: 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 *-- Node

The 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:

pythonCache · 37 lines
# 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.

pythonNode · 34 lines
# 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 out

The 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.

Edge cases
  • put on 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
  • delete of a missing key: return False, 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 None as a value: our get can't tell "missing" from "stored None". Either forbid None values 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.

pythonLRUPolicy · 46 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.

pythonBoundedCache · 47 lines
# 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:

pythonworker · 35 lines
# 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
text6 lines
['C', 'B', 'A']
evicted B
['D', 'A', 'C']
['C', 'D', 'A'] None
None 2 3
100 entries, 16000 lookups

Read 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 a Clock. Expire lazily: get treats 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, and frequency → ordered set of keys, plus the current minimum frequency. on_access moves a key from bucket f to f+1; victim is the oldest key in the minimum bucket. All O(1), and the cache is unchanged.
  • "Limit by bytes, not by count." Give put a size (or a sizer function), 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.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • 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: put of 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
Quick check4 questions · +5 XP each

LRU cache

  1. 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.

  2. 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.

  3. 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.

  4. 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.

End of LLD 02 · Design an LRU Cache
Finish LLD 02Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~2 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…