LRU Cache: Data Structure Deep Dive

Data Structures By Sarah Mitchell, Staff Software Architect 21 min read

1. Introduction and Core Concepts

An LRU Cache, or Least Recently Used Cache, is a fixed-size data structure that stores a limited number of items and evicts the least recently accessed entry when capacity is exceeded. It is one of the most fundamental caching strategies in computer science, balancing simplicity, efficiency, and high hit rates for workloads that exhibit temporal locality. The core principle is intuitive: if an item has not been used for a long time, it is less likely to be needed in the near future than an item that was just accessed.

Unlike a standard hash map or dictionary that can grow without bound, an lru cache enforces a strict capacity constraint. This makes it ideal for environments where memory is limited or where storing every possible result is infeasible. Every time an item is accessed via a get operation or updated via a put operation, it is promoted to the most recently used (MRU) position. The item at the opposite end, the least recently used (LRU) position, becomes the prime candidate for eviction.

The importance of the lru cache stems from the principle of locality of reference. Most real-world workloads, from web browsing to database queries, show that recently accessed data is likely to be accessed again soon. By keeping hot data in fast memory and discarding cold data, an LRU cache dramatically reduces expensive operations such as disk I/O, network requests, or complex computations. It is the backbone of operating system page replacement, web browser caches, database buffer pools, and content delivery networks.

Why LRU Matters

Without a cache eviction policy, systems would either run out of memory or suffer from unbounded latency. A naive cache that never evicts will eventually consume all available RAM, while a cache that evicts randomly may discard valuable data and cause frequent cache misses. LRU strikes an excellent middle ground by using recency as a proxy for future usefulness, which is both cheap to track and highly effective in practice.

Understanding lru cache design is also a rite of passage for software engineers. It elegantly combines two classic data structures — a hash map for O(1) lookups and a doubly linked list for O(1) ordering — to achieve optimal performance. Mastering this combination teaches critical lessons about trade-offs between time, space, and implementation complexity that apply far beyond caching itself.

Key Terminology

2. How It Works

An efficient lru cache achieves O(1) time for both get and put by fusing two data structures. A hash map (dictionary) provides instant key-based access to cache nodes, while a doubly linked list maintains the order of use from most recent to least recent. Neither structure alone is sufficient: a hash map has no notion of order, and a linked list alone would require O(n) search.

The doubly linked list is organized with dummy head and tail sentinel nodes to simplify edge cases. The node immediately after the head is always the MRU item, and the node immediately before the tail is always the LRU item. This arrangement allows insertion and removal at both ends in constant time without traversing the list. Each node stores a key, a value, and pointers to its previous and next nodes.

The hash map stores a mapping from key to the corresponding node object in the linked list. This indirection is crucial. When a key is requested, the map locates the node in O(1), and the list can then detach and reattach that node at the head in O(1). Without the map, we would need to scan the list to find the node, degrading performance to O(n).

The Core Operations

Get Operation: When get(key) is called, the cache checks the hash map. If the key is absent, it returns -1 or null to signal a miss. If present, it retrieves the node, removes it from its current position in the linked list, and moves it to the head (MRU position). The value is then returned. This move operation is what updates recency.

Put Operation: When put(key, value) is called, there are two cases. If the key already exists, the node’s value is updated and the node is moved to the head, just like a get. If the key is new, a new node is created, inserted at the head, and added to the hash map. If this insertion causes the size to exceed capacity, the LRU node at the tail is evicted — removed from both the linked list and the hash map.

Visualizing Recency

Think of the cache as a stack of plates where you always take the top plate and, whenever you use a plate from the middle, you move it to the top. The plate at the bottom is the one you have not touched for the longest time. Over a sequence of operations, frequently and recently used keys naturally bubble to the top, while stale keys sink to the bottom and are eventually discarded. This self-organizing behavior requires no timestamps or counters, only pointer manipulation.

This design guarantees that the order of the linked list always reflects access history. It is deterministic, easy to reason about, and avoids the overhead of maintaining access frequencies or expiration times unless explicitly required by a variant.

3. Implementation

Python offers multiple ways to implement an lru cache, ranging from a concise solution using OrderedDict to a classic interview-ready implementation with a manual doubly linked list. Below are four practical implementations with increasing control and complexity.

Implementation 1: Using OrderedDict for Simplicity

Python’s collections.OrderedDict remembers insertion order and provides move_to_end() and popitem(last=False) methods that map perfectly to LRU semantics. This is the most Pythonic and least error-prone approach for production code where you do not need to implement the list yourself.

from collections import OrderedDict

class LRUCacheOrderedDict:
    def __init__(self, capacity: int):
        if capacity <= 0:
            raise ValueError("Capacity must be > 0")
        self.capacity = capacity
        self.cache = OrderedDict()

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        # Move to end to mark as most recently used
        self.cache.move_to_end(key)
        return self.cache[key]

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            # popitem(last=False) removes least recently used
            self.cache.popitem(last=False)

Implementation 2: Classic Doubly Linked List + Hash Map

This is the canonical implementation expected in coding interviews. It explicitly manages pointers and achieves true O(1) operations without relying on OrderedDict internals. Dummy head and tail nodes eliminate null checks for edge cases.

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

class LRUCache:
    def __init__(self, capacity: int):
        if capacity <= 0:
            raise ValueError("Capacity must be > 0")
        self.capacity = capacity
        self.cache = {}  # key -> Node
        # Dummy head and tail to avoid edge checks
        self.head = Node(0, 0)
        self.tail = Node(0, 0)
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node: Node) -> None:
        prev_node = node.prev
        next_node = node.next
        prev_node.next = next_node
        next_node.prev = prev_node

    def _add_to_head(self, node: Node) -> None:
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self._remove(node)
        self._add_to_head(node)
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self._remove(node)
            self._add_to_head(node)
        else:
            new_node = Node(key, value)
            self.cache[key] = new_node
            self._add_to_head(new_node)
            if len(self.cache) > self.capacity:
                # Remove LRU node before tail
                lru = self.tail.prev
                self._remove(lru)
                del self.cache[lru.key]

Implementation 3: Using functools.lru_cache Decorator

For memoization of pure functions, Python’s standard library provides a battle-tested LRU cache out of the box. It is implemented in C and highly optimized, making it ideal for caching expensive function calls without writing cache logic manually.

from functools import lru_cache
import time

# Python's built-in memoization uses LRU eviction internally
@lru_cache(maxsize=128)
def expensive_fibonacci(n: int) -> int:
    if n < 2:
        return n
    return expensive_fibonacci(n-1) + expensive_fibonacci(n-2)

# First call computes, second call is O(1) cache hit
print(expensive_fibonacci(30))  # Computed
print(expensive_fibonacci(30))  # Retrieved from cache

# Inspect cache statistics
print(expensive_fibonacci.cache_info())
# CacheInfo(hits=..., misses=..., maxsize=128, currsize=...)

# Manual invalidation when needed
expensive_fibonacci.cache_clear()

Implementation 4: Thread-Safe Extension

In multi-threaded servers, cache operations must be atomic. Wrapping the core logic with a reentrant lock ensures that a get that moves a node to the head does not interleave with an eviction from another thread.

import threading

class ThreadSafeLRUCache(LRUCache):
    def __init__(self, capacity: int):
        super().__init__(capacity)
        self.lock = threading.RLock()

    def get(self, key: int) -> int:
        with self.lock:
            # All operations inside lock to ensure atomic move-to-head
            if key not in self.cache:
                return -1
            node = self.cache[key]
            self._remove(node)
            self._add_to_head(node)
            return node.value

    def put(self, key: int, value: int) -> None:
        with self.lock:
            if key in self.cache:
                node = self.cache[key]
                node.value = value
                self._remove(node)
                self._add_to_head(node)
            else:
                if len(self.cache) >= self.capacity:
                    lru = self.tail.prev
                    self._remove(lru)
                    del self.cache[lru.key]
                new_node = Node(key, value)
                self.cache[key] = new_node
                self._add_to_head(new_node)

# Usage example
cache = ThreadSafeLRUCache(2)
cache.put(1, 100)
cache.put(2, 200)
print(cache.get(1))  # Returns 100 and marks 1 as MRU
cache.put(3, 300)    # Evicts key 2 (LRU)
print(cache.get(2))  # Returns -1 (not found)

4. Step-by-Step Example

To solidify the mechanics, let us walk through a concrete sequence with a capacity of 3. We will track both the hash map contents and the linked list order from MRU (left) to LRU (right) after each operation.

Initial state: cache = LRUCache(3), list is empty: head <-> tail

Step 1: put(1, A) — Insert key 1. No eviction needed. List becomes: 1:A. Map: {1: Node(1,A)}. The cache now holds one entry and 1 is both MRU and LRU.

Step 2: put(2, B) — Insert key 2 at head. List: 2:B -> 1:A. Map: {1, 2}. Recency order reflects that 2 is newer than 1.

Step 3: put(3, C) — Insert key 3 at head. List: 3:C -> 2:B -> 1:A. Map: {1,2,3}. Cache is now full. The LRU item is 1:A at the tail.

Step 4: get(1) — Access key 1. This is a cache hit. Node 1:A is detached from the tail and moved to the head. List: 1:A -> 3:C -> 2:B. Note how 1 jumped from LRU to MRU, and 2:B is now the LRU. This promotion is the heart of LRU behavior.

Step 5: put(4, D) — Insert key 4. Capacity is exceeded, so the LRU node (2:B) is evicted. List: 4:D -> 1:A -> 3:C. Map: {1,3,4}. Key 2 is gone completely. If we later call get(2), it will return -1.

Step 6: put(3, C-updated) — Update existing key 3 with a new value. The node for 3 is found via the map, its value is updated, and it is moved to the head. List: 3:C-updated -> 4:D -> 1:A. No eviction occurs because we did not increase size; we just reordered.

Step 7: get(4) — Access key 4. List becomes: 4:D -> 3:C-updated -> 1:A. Now 1:A is again the LRU and will be evicted next.

This trace demonstrates two critical invariants: every get or put on an existing key refreshes its recency, and eviction always targets the tail. The sequence also shows why workload matters — if the workload repeatedly accesses a small working set (like keys 1,3,4), those keys stay resident while cold keys are naturally purged.

Operation Summary Table

Step Operation Cache Order (MRU -> LRU) Evicted Result
1 put(1, A) [1:A] - Inserted
2 put(2, B) [2:B, 1:A] - Inserted
3 put(3, C) [3:C, 2:B, 1:A] - Inserted, cache full
4 get(1) [1:A, 3:C, 2:B] - Hit, 1 promoted
5 put(4, D) [4:D, 1:A, 3:C] 2:B Evicted LRU
6 put(3, C*) [3:C*, 4:D, 1:A] - Updated & promoted
7 get(4) [4:D, 3:C*, 1:A] - Hit, 4 promoted

5. Complexity Analysis

The hallmark of a well-implemented lru cache is constant-time operations. By combining a hash map and a doubly linked list, both get and put run in O(1) average time. This is a significant improvement over naive approaches such as using an array or singly linked list, which would require O(n) scans.

For get, the hash map lookup is O(1) average, O(n) worst-case due to hash collisions, though with a good hash function collisions are rare. The subsequent pointer manipulations to detach and reattach the node are strictly O(1). Similarly, put involves at most one hash map insertion, one linked list insertion at the head, and possibly one removal at the tail — all O(1).

Space complexity is O(capacity) for storing the cache entries, plus O(capacity) for the hash map overhead and linked list pointers. In practice, this is O(n) where n is the capacity. The dummy head and tail nodes add O(1) extra space. If values are large objects, the dominant memory cost is the values themselves, not the bookkeeping structures.

It is important to distinguish between the optimal doubly linked list implementation and simpler alternatives. An implementation using a Python list to maintain order would have O(n) time for get because moving an element to the front requires shifting. An OrderedDict implementation is O(1) but with higher constant factors due to Python-level method calls. The table below summarizes the trade-offs.

Operation Best Case Average Case Worst Case Space
get(key) O(1) O(1) O(n) with hash collisions O(capacity)
put(key, value) O(1) O(1) O(n) with hash collisions O(capacity)
delete(key) O(1) O(1) O(n) O(capacity)
eviction O(1) O(1) O(1) -
iteration / clear O(n) O(n) O(n) O(1) auxiliary
Naive list-based get O(1) if MRU O(n) O(n) O(capacity)

Best case for get occurs when the requested key is already at the head, requiring only a hash lookup and no pointer updates in some optimized variants. Worst case for hash-based structures is pathological collision chains, though modern Python dictionaries use randomized hashing and open addressing to make this extremely unlikely. For most engineering purposes, you can treat LRU operations as O(1).

6. Variations and Optimizations

While the classic lru cache is powerful, several variations address its limitations in specific workloads. Understanding these helps you choose the right policy for your system.

LFU and LRU-K

Least Frequently Used (LFU) evicts the item with the lowest access count, not the oldest. It excels when some items are hot and accessed many times, but it can retain stale frequent items that are no longer relevant. LRU-K generalizes LRU by considering the K-th most recent access, with LRU-2 being common in database buffer pools. LRU-2 evicts based on the penultimate access time, which better handles sequential scans that would otherwise pollute a pure LRU cache.

2Q and ARC

2Q (Two Queues) uses one FIFO queue for items seen once and an LRU queue for items seen multiple times, protecting the cache from scan pollution. ARC (Adaptive Replacement Cache) dynamically balances between recency and frequency by maintaining two lists and adapting to workload changes. ARC is used in IBM storage systems and ZFS, and it self-tunes without manual parameter setting.

TTL and Expiration

Many real caches combine LRU with Time-To-Live (TTL). Each entry expires after a fixed duration, regardless of recency. This is essential for data that becomes stale, such as DNS records, API responses, or session tokens. Implementation typically adds an expiration timestamp to each node and lazily evicts expired entries on get or via a background sweeper.

Segmented LRU and SLRU

Segmented LRU divides the cache into a probationary segment and a protected segment. New entries enter the probationary segment; only if accessed again do they graduate to the protected segment. This prevents one-hit wonders from evicting truly hot data. It is widely used in databases like MySQL’s InnoDB buffer pool.

Optimizations

7. Real-World Applications

LRU caching is ubiquitous in systems where speed matters. Its ability to keep working sets in fast memory underpins performance across the stack, from hardware to user-facing applications.

Web Servers and CDNs

Web servers like Nginx and Varnish use LRU to cache rendered pages, images, and API responses in RAM. A cache hit avoids disk reads or upstream service calls, cutting latency from hundreds of milliseconds to single digits. Content Delivery Networks cache edge content with LRU plus TTL, ensuring popular assets stay at the edge while stale content is purged. Browser caches also use LRU to manage limited disk and memory for images, scripts, and CSS files.

Databases and Storage

Database buffer pools cache disk pages in memory. InnoDB, PostgreSQL, and Oracle all use variants of LRU to decide which pages to keep. A well-tuned buffer pool can achieve >99% hit rates, making the database appear in-memory for hot data. Operating systems use LRU for virtual memory page replacement — when RAM is full, the least recently used page is swapped to disk. File systems cache inodes and directory entries with LRU as well.

Networks and Distributed Systems

DNS resolvers cache lookups with LRU+TTL, routers cache ARP entries and forwarding decisions, and application-level caches like Redis and Memcached often offer LRU eviction when memory limits are reached (maxmemory-policy allkeys-lru). In microservices, client-side LRU caches reduce redundant calls to downstream services, improving resilience and reducing load.

Application-Level Use Cases

Use Case Typical Capacity Why LRU Fits Example Technology
Image Thumbnail Cache 100 - 1000 items Recently viewed images likely revisited Mobile app, LRU in-memory cache
Database Query Result Cache 500 - 10000 queries Repeated queries for hot keys Redis, Memcached, ORM cache
Compiled Regex / Template Cache 50 - 200 entries Same patterns reused frequently Python re cache, Jinja2
API Rate Limit & Session Store 10000+ sessions Active sessions are recent Web framework session cache
CPU Cache Replacement KB to MB Temporal locality in code Hardware L1/L2 cache (pseudo-LRU)
Search Autocomplete 1000 suggestions Recent queries predict next keystrokes Elasticsearch, in-app trie + LRU

Even machine learning pipelines benefit: feature stores cache computed features, and data loaders cache decoded images to avoid repeated JPEG decompression during training epochs. The pattern is universal — whenever recomputation or refetching is expensive and access is skewed, an lru cache pays dividends.

8. Comparison with Alternatives

Choosing an eviction policy requires understanding workload characteristics. LRU is excellent for recency-biased workloads but not always optimal. The table below compares LRU with common alternatives across key dimensions.

Feature / Policy LRU (Least Recently Used) LFU (Least Frequently Used) FIFO (First-In First-Out) Random MRU (Most Recently Used)
Eviction Criterion Oldest access time Lowest access count Oldest insertion time Random entry Newest access time
Hit Rate (general workload) High for temporal locality High for skewed frequency Moderate, ignores recency Low but cheap Poor, except for scans
Implementation Complexity Moderate (hash + list) Higher (heap / counters) Low (queue) Very low Moderate
Time per Operation O(1) O(log n) with heap, O(1) with advanced O(1) O(1) O(1)
Scan Pollution Resistance Poor (scans evict hot data) Good Poor Moderate Good for loops
Memory Overhead Pointers + map Counters + heap Queue pointers Minimal Pointers + map
Best Use Case Web cache, buffer pool CDN with hot objects Simple pipelines Large caches, low cost Database scans, loop access
Adaptivity Static recency Static frequency None None Static

In practice, many systems use hybrid policies. For example, TinyLFU admission with LRU eviction (used in Caffeine cache) combines frequency filtering with recency ordering to get the best of both worlds. FIFO is sometimes preferred when recency tracking overhead is unacceptable, and Random eviction is surprisingly effective for large caches due to its simplicity and lack of metadata. MRU is counterintuitive but optimal for workloads that repeatedly scan a dataset larger than the cache, such as nested-loop joins, where LRU would thrash.

When evaluating, benchmark with your actual access trace. A cache simulator that replays production logs and measures hit rate for different policies and sizes is invaluable. Often, increasing capacity by 10% yields more benefit than switching policies, so consider sizing alongside algorithm choice.

9. Common Mistakes and Best Practices

Even though the lru cache concept is simple, implementation pitfalls can cause subtle bugs, performance regressions, or memory leaks.

Common Mistakes

Best Practices

Finally, remember that caching is an optimization, not a correctness mechanism. Always have a fallback path for cache misses, and ensure the system behaves correctly even if the cache is empty or disabled. This makes the cache an accelerator rather than a single point of failure.

10. Interview Questions

LRU Cache is a staple of technical interviews because it tests hash maps, linked lists, and design trade-offs in one problem. Below are common questions with high-level approaches.

Q1: Design and Implement LRU Cache with O(1) get and put

This is the classic LeetCode 146 problem. Expected solution is the doubly linked list plus hash map described earlier. Interviewers look for correct handling of capacity, promotion on get, update on existing put, and eviction of the tail. Be ready to code it on a whiteboard, explain why a singly linked list is insufficient (O(n) removal), and analyze complexity. Bonus points for discussing OrderedDict alternative and its trade-offs.

Q2: How would you make your LRU Cache thread-safe? What are the performance implications?

Answer: Wrap get and put with a lock (e.g., RLock) to make pointer updates atomic. Discuss coarse-grained vs fine-grained locking, lock striping for sharded caches, and read-write locks that allow concurrent reads. Mention that Python’s GIL does not guarantee atomicity for multi-step pointer manipulations, so explicit locking is still needed. For high throughput, consider using concurrent.futures or lock-free structures, but note complexity.

Q3: What happens if the workload is a sequential scan larger than the cache? How would you improve LRU?

Answer: Pure LRU suffers from scan pollution — a large sequential scan evicts all hot data, causing thrashing and a near-zero hit rate after the scan. Solutions include LRU-K, 2Q, ARC, or segmented LRU that requires two accesses before promotion to the protected segment. Alternatively, detect scans and bypass the cache for them, or use MRU for scan workloads. This question tests understanding of LRU limitations, not just implementation.

Q4: Implement LFU Cache and compare it to LRU. When would you choose one over the other?

Answer: LFU requires tracking frequencies, typically with a hash map from frequency to a doubly linked list of keys plus a min-frequency tracker, achieving O(1) with careful design (LeetCode 460). Choose LRU when recency predicts future access (temporal locality), such as web requests or buffer pools. Choose LFU when some items are persistently hot regardless of recent gaps, such as popular videos or dictionary words. Discuss hybrid TinyLFU + LRU used in modern caches like Caffeine.

Q5: How would you add TTL expiration to an LRU Cache? How do you handle lazy vs active expiration?

Answer: Store expire_at timestamp per node. On get, check if expired and if so, remove and treat as miss (lazy expiration). For active expiration, run a background thread or periodic sweep to purge expired entries, or use a heap of expiration times to efficiently find the next to expire. Discuss trade-offs: lazy is simple and O(1) but may retain expired memory; active reclaims memory promptly but adds overhead. Mention that Redis uses both lazy and active strategies.

Preparing for these questions involves not just coding the structure but articulating why each design choice was made, what the failure modes are, and how you would monitor and tune the cache in production.

Frequently Asked Questions

What is Lru Cache and how does it work?

Lru Cache is a fundamental concept in computer science. It involves specific techniques and data organization methods designed to solve computational problems efficiently. Understanding the core mechanics helps developers choose the right approach for their applications.

What is the time complexity of Lru Cache?

The time complexity of Lru Cache varies depending on the specific operation and implementation. Common complexities range from O(1) constant time for basic operations to O(n log n) or O(n²) for more complex operations. The article above provides detailed complexity analysis for each operation.

When should I use Lru Cache in my projects?

Use Lru Cache when your application requires the specific performance characteristics it provides. Consider factors like data size, operation frequency, memory constraints, and whether the problem domain matches the strengths of Lru Cache. The real-world applications section above covers common use cases.

What are the advantages and disadvantages of Lru Cache?

The main advantages of Lru Cache include efficient performance for its target use cases and well-understood theoretical guarantees. Disadvantages may include implementation complexity, memory overhead, or performance limitations in certain edge cases. Always benchmark against alternatives for your specific workload.

How does Lru Cache compare to its alternatives?

Lru Cache offers distinct trade-offs compared to alternative approaches. While it excels in certain scenarios, other algorithms or data structures may be better suited for different problem constraints. The comparison table in the article provides a detailed analysis of these trade-offs.