•CASE STUDY

Word Store with Lexicographic Range Queries

5 min read·830 words·Intermediate

Asked at

1 candidate report in Oct 2025

How to use this case study

SDE-2 / Mid

  • Explain keeping words sorted so a range [L
  • R] is a contiguous slice found with binary search

SDE-3 / Senior

  • Choose an on-disk structure (B+ tree or sorted SSTables/LSM) for data larger than memory
  • With inserts and deletes
  • Count page reads per query

Staff / Principal

  • Discuss sharding by key range
  • Pagination of big results
  • Prefix queries as ranges
  • Trade-offs vs a trie

Problem RestatementProblem

Google asked: design storage for a large set of words (strings), stored persistently, that supports:

  • add and delete words,
  • range query: given [L, R], return all words w with L ≤ w ≤ R in dictionary (lexicographic) order, possibly with a limit or pagination.

The data may be larger than memory.

Key Insight: Keep Words Sorted

If words are kept sorted, all words in [L, R] sit next to each other. A range query = find the first word ≥ L (binary search), then scan forward until a word > R. The cost is O(log n + k), where k = the number of results.

A prefix query ("all words starting with 'app'") is just a range: ["app", "app￿"], or [app, apq).

In Memory (small data)

A sorted array plus binary search (bisect) for queries, but inserts are O(n). Better: a balanced tree / skip list / sorted container, with O(log n) insert, delete and seek.

from sortedcontainers import SortedList   # balanced sorted structure

words = SortedList()
def add(w): words.add(w)
def delete(w): words.discard(w)
def range_query(lo, hi, limit=100):
    start = words.bisect_left(lo)
    out = []
    for w in words.islice(start):
        if w > hi or len(out) == limit: break
        out.append(w)
    return out

On Disk (data larger than memory)

Option A: B+ tree (like a database index):
  • Keys are sorted in leaf pages linked left to right. Upper levels are small and cached in RAM.
  • Query: descend the tree to the leaf containing L (~1 disk read, since the upper levels are in memory), then read leaf pages sequentially until R.
  • Inserts and deletes update pages in place (with page splits and merges).

Option B: LSM tree (write-optimized, like LevelDB/RocksDB):
  • Writes go to an in-memory sorted buffer (plus a write-ahead log), flushed as immutable sorted files (SSTables), merged in the background.
  • A range query merges iterators over the memtable and the SSTables (like merging sorted lists), skipping deleted words (tombstones).
  • Great when writes are heavy. Range reads touch several files (mitigated by compaction and sparse indexes).

Architecture diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
    Q["range(L, R)"] --> IDX["Top levels in RAM - find leaf for L"]
    IDX --> P1["Leaf page with L"]
    P1 -->|"next leaf"| P2["Leaf page"]
    P2 -->|"next leaf"| P3["... until word > R"]
Compression: sorted words share prefixes ("apple", "applet", "applied"), so prefix compression in pages saves a lot of space.

Scaling Out

  • Range-partition words across machines by key ranges (a–c, d–f, ...), with split points chosen so shards are equal in size. A query touches only the shards overlapping [L, R], in order.
  • Split hot or large ranges automatically (as Bigtable/HBase do).
  • Pagination: return a cursor = the last word returned. The next page starts just after it (seek to > cursor).

Deep Dive — Why a sorted structure, and not a trieDeep dive

[L, R] range queries over a large persistent word set. The structure choice is the whole design, and the popular answer is the wrong one.
Weak

A hash index

Hash each word to a bucket. Add, delete and exact lookup are all O(1).

Architecture diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
  W["apple, banana, cherry"] --> H["hash()"]
  H --> B["Scattered across buckets in no order"]
  Q["Range query [ban, car]"] --> SCAN["No way to find neighbours"]
  SCAN --> ALL["Scan every bucket and test each word"]
  ALL --> ON["O(n) per query - the index contributes nothing"]

Hashing deliberately destroys order, and order is exactly what the query needs. It is the right structure for the operations this problem does not ask for.

Good

A trie

Store words character by character. Prefix lookups are excellent, and a range query is an in-order traversal between two bounds.

Correct, and it is what most people reach for. Two practical problems. Memory: a node with child pointers per character costs far more than the characters themselves, often several times the raw data. And disk: nodes are small and scattered, so a traversal is a chain of pointer dereferences across pages — the opposite of what a block device wants. A trie is an in-memory structure that does not survive the move to disk gracefully.

Best

Keep the words sorted: a B+ tree or an LSM tree

Architecture diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
  SORT["Words kept in sorted order"] --> SEEK["Seek to L - O(log n)"]
  SEEK --> SCAN["Scan sequentially until R"]
  SCAN --> OUT["Results - cost is proportional to what is returned"]
  BP["B+ tree - leaves are sorted pages, linked"] --> DISK["One page read per few hundred words"]
  LSM["LSM tree - sorted runs merged in the background"] --> WR["Sequential writes, ranges merge across runs"]
  • A range is a seek plus a sequential scan, so the cost is proportional to the size of the answer rather than the size of the data.
  • Pages, not pointers. Leaves hold hundreds of words in sorted order, so one disk read produces a large run of results and the scan follows linked leaves sequentially — the access pattern disks are built for.
  • Pick by workload. A B+ tree gives predictable in-place reads and updates; an LSM tree gives much faster writes at the cost of merging reads across sorted runs and background compaction.

Prefix queries, the trie's strong suit, come along for free: prefix* is the range [prefix, prefix + '\xff'], which is the same seek-and-scan. So the sorted structure covers both access patterns while staying compact and disk-friendly — and that is the argument to make when someone suggests a trie.

Wrap-UpWrap-up

Keep words sorted so any [L, R] range is a contiguous run: seek to the first word ≥ L with binary search or tree descent, then scan until > R, giving O(log n + k). In memory, use a balanced sorted structure. On disk, use a B+ tree (read-friendly) or an LSM tree (write-friendly) with prefix compression. Scale by range-partitioning with automatic splits, paginate with the last word as a cursor, and treat prefix queries as ranges.

More Case Studies

Frequently Asked Questions

What is the Word Store with Lexicographic Range Queries system design question?

Word Store with Lexicographic Range Queries is a system design interview question asked at FAANG companies. It covers storage, algorithms, databases and tests your ability to design scalable, production-ready systems. InterviewSkool's breakdown walks you through requirements, API design, architecture, and trade-offs.

Which companies ask the Word Store with Lexicographic Range Queries question?

Google have reportedly asked variations of this question in system design interviews. The exact wording may differ, but the core design challenges remain the same.

How should I prepare for the Word Store with Lexicographic Range Queries interview question?

Start with the problem statement and scale estimates, then design the high-level architecture. Focus on the core components, data model, and API design. InterviewSkool's breakdown covers the full solution with mermaid diagrams and trade-off analysis to help you prep efficiently.

What level is the Word Store with Lexicographic Range Queries question?

This question is suitable for SDE-2, SDE-3, and Staff engineer interviews. The level guidance on this page provides specific tips for each level — SDE-2 candidates should focus on core architecture, while Staff engineers should discuss trade-offs, monitoring, and incremental rollouts.

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with InterviewSkool's AI interviewer.

Start System Design Interview →