Database Indexing for System Design Interviews
Without an index, finding one row in millions means scanning every page of the table. Indexes maintain a separate structure that points straight at the data — this deep dive covers how that works under the hood and which index type fits which problem.
Database performance can make or break an application. Picture searching for a user's profile by email in a table of millions of rows. With no optimization, the database checks each row in order, scanning every record until it finds a match — like searching a library by opening every book one at a time.
Indexes are the fix. By maintaining separate data structures optimized for searching, they let the database jump to the exact records you need without examining every row. Knowing when to add an index, on which columns, and of what type is a recurring interview focus: basic strategies are expected of mid-level engineers, and mastery of the different types and their trade-offs is expected at staff level.
01 How Database Indexes Work
Unless you're interviewing for a database-internals role, the storage details below won't be asked directly. But they're the foundation for understanding why indexes exist at all.
Data lives on disk, in pages
When data is stored in a database it's ultimately written to disk as files. The main table is typically a heap file — a collection of rows in no particular order, like a notebook where you write entries as they arrive. The data lives on disk (SSDs today), but it can only be processed once it's loaded into RAM. So every query requires reading data from disk into memory.
The unit of disk I/O. A page is usually 8 KB and holds roughly 100 rows. Databases read and write whole pages, not individual rows.
Querying without an index means scanning every page one by one — loading each into memory to check whether it contains the target. With millions of pages that's millions of relatively slow disk reads to find a single record: flipping through every page of a massive book to find one word.
Put numbers on it. With 100 million users and ~100 rows per page, that's ~1 million pages. Each SSD→RAM round trip is about 100 microseconds, so a worst-case full scan is roughly 100 seconds.
Prefetching and other optimizations realistically pull that down to ~3–5 seconds — but the point stands: that's far longer than a user will wait to look up one record by ID.
How the index changes the access pattern
An index transforms the access pattern. Instead of reading every page sequentially, the index gives a structured path directly to the data, minimizing how many pages you touch by telling you exactly which page holds your target. It's the difference between reading every page of a book and using the table of contents to jump to the right one.
An index is a data structure stored on disk that acts as a map: it tells you which page a given item lives on. On a query, you first pull the index into memory, read off the page number, and then read only that page — turning a scan of ~1 million pages into a couple of targeted reads.
A common misconception is that SSDs make random access "free." Random access is still meaningfully slower than sequential access even on SSDs — the gap is smaller than on HDDs but real. On HDDs with large datasets it's dramatic, which makes proper indexing critical.
Indexes aren't free — the cost side
Every index costs additional disk space, sometimes nearly as much as the original data. Writes take a hit too: inserting or updating a row means the database must update the main table and every index on it, so one write can trigger several disk writes.
- Write-heavy, read-light tables — e.g. a logging table with constant inserts but rare queries. Index maintenance overhead may not pay off.
- Small tables (a few hundred rows) — traversing an index can cost more than a simple sequential scan.
In practice the memory impact is often overblown — modern databases have smart buffer-pool management that softens the cost of multiple indexes. Still, monitor index usage and avoid creating indexes that don't earn significant benefit.
02 B-Tree Indexes
The most common index type. A balanced tree that keeps data sorted and minimizes the disk reads needed to find any value.
A self-balancing tree that maintains sorted data and supports efficient insert, delete, and search. Unlike a binary tree (≤2 children), a B-tree node can have many children — hundreds in practice. Each node is a sorted array of keys plus pointers, either to child nodes or to data pages.
The rules every B-tree node follows
- All leaf nodes sit at the same depth (balanced).
- Each node holds between m/2 and m keys (m = order of the tree).
- A node with k keys has exactly k+1 children.
- Keys within a node are kept in sorted order.
This structure maps perfectly onto disk storage: each node is sized to fit in a single 8 KB page, maximizing I/O efficiency. When PostgreSQL looks up id=350, it may read only 2–3 pages: the root, maybe one internal node, then the leaf.
Query: SELECT * FROM users WHERE age = 51, with an index built on age.
- Pull the root page into memory. 51 is greater than 50, less than 90 → follow that pointer.
- Pull the next internal page. 51 < 55 → that pointer maps to page 3.
- Pull page 3 — where all the age-51 users live. Done in a few reads instead of a full scan.
Now make it a range query (age > 50): read the root, follow both child pointers that satisfy "> 50," then follow all of their leaf pointers — loading every page of matching users. Because leaves are sorted, the range comes out in order for free.
Real-world examples
PostgreSQL uses B-trees for almost everything — primary keys, unique constraints, and most regular indexes. Creating this table:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE
);
automatically creates two B-tree indexes — one for the primary key, one for the unique email constraint. Both rely on sorted order for uniqueness checks and range queries.
DynamoDB organizes items within a partition in sort-key order for efficient range queries; its internals aren't fully documented but it's understood to use an LSM-style engine rather than a B-tree. Even MongoDB uses B-trees — specifically B+ trees, a variant where all data lives in the leaf nodes:
db.users.createIndex({ "email": 1 });
This creates a B-tree mapping email values to document locations.
- Maintain sorted order → efficient range queries and
ORDER BY. - Self-balancing → predictable performance as data grows.
- Structure matches disk storage → minimal I/O.
- Handle both equality (
email = 'x') and range (age > 25) searches well. - Stay balanced under random inserts/deletes — no performance cliffs.
If you have to pick an index and you're unsure, B-trees are the safe bet — the Swiss-army knife of indexes.
03 LSM Trees (Log-Structured Merge Trees)
B-trees handle balanced workloads well. But what happens when writes dominate — say a metrics system like Datadog ingesting millions of readings per second?
With a B-tree, each write means finding the right leaf page, reading it into memory, updating it, and writing it back. At a few thousand writes/sec that's fine. At 100,000 writes/sec those random disk seeks become the bottleneck — like updating folders scattered throughout a filing cabinet, spending more time searching than storing.
With B-trees, each index is its own structure you can add to any column. An LSM tree is the storage format for the entire table, sorted by primary key. Primary-key lookups are extremely fast; secondary indexes need extra structures (Cassandra, DynamoDB via GSIs/LSIs support them with different performance). Instead of updating in place, LSM trees are append-only — built for write-heavy workloads.
How LSM trees work
The trick: batch writes in memory and flush them to disk sequentially, converting many small random writes into fewer large sequential ones.
- Memtable — new writes go into an in-memory sorted structure (skip list / red-black tree). Extremely fast, all in RAM.
- Write-Ahead Log (WAL) — every write is also appended to a log on disk for durability. Sequential append, much faster than random writes.
- Flush to SSTable — when the memtable hits a size limit (a few MB) it's frozen and written to disk as one immutable Sorted String Table in a single sequential write.
- Compaction — a background process periodically merges accumulated SSTables, removing duplicates and deleted entries to keep file count and read performance in check.
The cost: reads get harder
Where a B-tree finds any record in 2–3 reads, an LSM read must check multiple places, newest first: the memtable → any immutable memtables awaiting flush → all SSTables on disk. A single point query might touch dozens of files in the worst case — like hunting for a document that could be in your desk drawer, a filing cabinet, or any of several archive boxes.
- Bloom filters — a probabilistic structure per SSTable that says a key is definitely not here, letting you skip most files. A "maybe" still requires a check, but it eliminates the definite misses.
- Sparse indexes — since SSTables are sorted, they record the key range of each block; if you want
user_id=500and a table only holds 1000–2000, skip it entirely. - Compaction strategies — size-tiered minimizes write amplification but leaves more files to check; leveled keeps fewer files but rewrites more often.
LSM trees fundamentally trade read performance for write performance. Reach for them when writes vastly outnumber reads — metrics collection, audit logs, IoT platforms, time-series and analytics. For a user-facing app where every page load fires several queries, B-trees usually win.
In the wild: Cassandra stores Netflix's billions of viewing events without slowing playback; RocksDB (Facebook) is the storage engine behind many databases, handling millions of social interactions/sec; DynamoDB is understood to use an LSM-style architecture for high write throughput (its exact internals aren't public, and it does not switch engines based on access patterns).
04 Hash Indexes
A specialized tool: blazing-fast exact-match lookups, and nothing else.
A persistent hashmap that maps indexed values to row locations. The database keeps an array of buckets; to index a value it hashes the value to pick a bucket, which stores a pointer to the row. Lookups are average O(1) — trading flexibility for speed.
# hash index on email
buckets[hash("alice@example.com")] -> [ptr to page 1]
buckets[hash("bob@example.com")] -> [ptr to page 2]
Collisions are handled by allowing multiple entries per bucket — many systems use chaining with overflow storage (PostgreSQL's hash indexes link buckets to overflow pages). To find a user by email: hash it, go to the bucket, follow the pointer.
The very thing that makes hash indexes fast — scattering similar values across buckets — makes them useless for range queries or sorting. Nearby values end up nowhere near each other.
Hash indexes are rarely used in production databases. B-trees perform nearly as well for exact matches and support ranges and sorting. The common home for hash indexes is in-memory stores like Redis, where disk-I/O patterns don't matter. Useful historically and for caching — but a real database will usually give you a B-tree, not a hash index.
PostgreSQL supports hash indexes but doesn't use them by default; as its docs note, "B-trees can handle equality comparisons almost as efficiently as hash indexes." Redis uses hash tables as its primary structure because all data is in memory; MySQL's MEMORY engine defaulted to hash indexes for the same reason.
Consider a hash index only when you need the absolute fastest exact-match lookups, will never need ranges or sorting, and have plenty of memory. Bruce Momjian: "Hash indexes solve a problem we rarely have." Don't overemphasize them in an interview — leaning on them can read as out of touch with real-world practice.
05 Geospatial Indexes
Specialized in practice, but a favorite interview topic thanks to location apps like Uber, Yelp, and Find My Friends. The rise of proximity search made this a common focus.
Why regular indexes fall short on location data
Say we're building Yelp: millions of restaurants, each with a latitude and longitude, and a user wants "restaurants within 5 miles of me." The naive approach is two B-tree indexes:
CREATE INDEX idx_lat ON restaurants(latitude);
CREATE INDEX idx_lng ON restaurants(longitude);
This falls apart because we're solving a 2D spatial problem with two independent 1D indexes.
Use the latitude index and you get every restaurant in that latitude band — a strip circling the globe — then must point-check each for longitude. Combine both indexes (an index intersection) and the database merges two large result sets, producing a rectangle far bigger than your circular radius, still needing a distance filter. We need indexes that understand 2D relationships.
Geohash, quadtree, and R-tree. All solve the same problem: preserve spatial proximity inside the index structure. Interviewers mainly want to see that you understand why regular indexes fall short and can reason about at least one solution — deep expertise in all three is only needed for location-specific roles.
Geohash — turn 2D into a 1D string
The simplest to understand, and beautifully so: convert a 2D location into a 1D string that preserves proximity. Divide the world into four squares labeled 0–3, then divide each of those into four, and so on. Each division adds precision. Real geohashes use base-32 encoding to make strings like dr5ru; the longer the string, the more precise:
| Geohash prefix | Precision |
|---|---|
9q8y | roughly all of San Francisco |
9q8yy | the Mission District |
9q8yyk | a specific city block |
Locations close together usually share a similar prefix. Two restaurants on the same block might both start with 9q8yyk. Once locations are ordered strings, you can index them with an ordinary B-tree — and B-trees already excel at prefix matching and range queries. No special spatial structure needed.
CREATE INDEX idx_geohash ON restaurants(geohash);
Finding nearby places becomes a prefix range scan. For radius queries you also include adjacent geohash cells (your circle may cross a cell boundary) — still just a handful of prefix range scans. This is exactly how Redis's geospatial commands work internally:
GEOADD restaurants -122.4194 37.7749 "Restaurant A"
GEOSEARCH restaurants FROMLONLAT -122.4194 37.7749 BYRADIUS 5 mi
(GEORADIUS is deprecated in favor of GEOSEARCH.)
Two points physically close can have different prefixes if they fall on opposite sides of a major grid division — like restaurants across a street that marks a geohash boundary. For most applications this isn't significant enough to matter.
Quadtree — subdivide only where it's dense
Instead of turning coordinates into strings, a quadtree directly partitions space into a tree. Start with one square over the whole area. When a square holds more than a threshold of points (the k value, typically 4–8), split it into four quadrants; keep recursing until each region is sparse enough or you hit a max depth.
Imagine each dot is a business. Split the world into four grids → a tree with four children, each pointing to the businesses in its grid. A dense (say blue) cell exceeds k, so it splits again into four; if one of those children is still too dense, it splits again. To find a business you just walk down the tree. Like the B-tree, this index ends up stored on disk.
The key advantage is adaptive resolution — dense areas subdivide finely while sparse regions stay coarse. The cost: unlike geohash, quadtrees need a specialized tree structure rather than reusing existing B-trees, which is why most modern databases prefer geohash or R-tree variants.
Though rare in production today, quadtrees are foundational — recursive spatial subdivision is the basis for R-trees, and quadtree-like structures organize map tiles at different zoom levels (e.g. Google Maps).
R-tree — flexible overlapping rectangles
R-trees are the default spatial index in modern databases (PostgreSQL/PostGIS, MySQL). Rather than splitting into fixed quadrants, they use flexible, overlapping rectangles that adapt to where the data actually clusters. Organizing photos on a table: a quadtree divides the table into equal quarters and keeps subdividing; an R-tree draws natural overlapping groupings around clusters of nearby photos.
Searching for restaurants in San Francisco, an R-tree finds the big rectangle for the city, then drills through progressively smaller overlapping rectangles down to individual locations. Because rectangles adapt, a single R-tree can index both points and larger shapes — restaurant pins, delivery-zone polygons, road networks — in one structure. Quadtrees struggle with mixed data, subdividing endlessly to isolate shapes.
Overlapping rectangles sometimes force searching multiple branches. Modern implementations use smart algorithms to balance overlap against tree depth, tuned to how databases read from disk — this balance of flexibility and disk efficiency is why R-trees became the production standard.
Geohashing is very popular today (it's Redis's default) — quick, and it rides on B-trees that already exist with just a front-end algorithm. Quadtrees were foundational but aren't much used in production now. R-trees are what production uses — e.g. the PostGIS extension for PostgreSQL. When you see 2D lat/long data in an interview, reach for a geospatial index and name which one you'd use.
Focus on explaining the problem, then contrast a hash-based approach with a tree-based one:
"B-trees don't work well for spatial data because they treat latitude and longitude as independent dimensions. Geohash is a hash-based approach that converts 2D coordinates into a 1D string preserving proximity, so we can use a regular B-tree on the geohash strings. Tree-based approaches like R-trees offer more flexibility and accuracy by grouping nearby objects into overlapping bounding rectangles."
06 Inverted Indexes
B-trees are great for exact matches and ranges but fail at searching through text content.
SELECT * FROM posts WHERE content LIKE '%database%';
B-trees sort strings lexicographically, so they only help with prefix matches ('database%') or suffix matches (on reversed content). When the pattern can match anywhere inside the text ('%database%'), the database has no choice but a full table scan, reading every character of every row. This gets exponentially slower as content grows.
It flips the relationship between documents and their content: instead of storing documents with their words, it stores words with the documents that contain them — like the index at the back of a textbook. Look up "ACID" and instantly get every page that discusses it.
Consider three posts:
doc1: "B-trees are fast and reliable"
doc2: "Hash tables are fast but limited"
doc3: "B-trees handle range queries well"
The inverted index maps each token to the documents it appears in:
b-trees -> [doc1, doc3]
fast -> [doc1, doc2]
reliable -> [doc1]
hash -> [doc2]
range -> [doc3]
queries -> [doc3]
A search for fast just looks up the token and returns [doc1, doc2] — pointers to the pages holding those rows.
Production indexes (Elasticsearch, Lucene) run text through an analysis pipeline before storing it: tokenize → lowercase → remove stop words ("the", "and") → often stem to root forms. So a search for "Databases" matches "database", "DATABASE", even "database's". On top they add term-frequency analysis, relevance scoring, fuzzy matching, and phrase queries.
You'll find inverted indexes wherever real text search happens — searching GitHub repos, Slack history, or documentation. The trade-offs: substantial storage overhead and careful updates (changing a document means updating entries for every term it contains). For making text searchable, that's a price worth paying.
You're unlikely to face deep implementation questions. Just know the reverse mapping from keywords → documents, and name a technology that provides it: Elasticsearch, Lucene, or PostgreSQL full-text search.
07 Index Optimization Patterns
Beyond picking a type, experienced engineers analyze read/write patterns and craft indexes that support their actual queries. Two patterns dominate.
Composite indexes — the most common pattern
Instead of one index per column, combine multiple columns into a single index in a specific order, matching how you actually query. A typical feed query:
SELECT * FROM posts
WHERE user_id = 123
AND created_at > '2024-01-01'
ORDER BY created_at DESC;
Two separate indexes force the database to find posts by user, find posts after the date, intersect, then sort. A composite index does it in one shot:
CREATE INDEX idx_user_time ON posts(user_id, created_at);
Under the hood this is a B-tree whose key is a tuple of both columns, sorted by user_id first, then created_at:
(1, 2024-01-01)
(1, 2024-01-02)
(1, 2024-01-03)
(2, 2024-01-01)
(3, 2024-01-01)
The database seeks to the first user_id=123 entry and scans sequentially until it passes the date range. Because entries are already sorted by created_at within each user, you get filtering and sorting for free — a 2D query collapsed into a 1D ordered scan.
You can only use a composite index efficiently for prefixes of its column list. (user_id, created_at) is great for filtering on user_id first, but useless for a query that filters on created_at alone. The classic advice is "order columns most-selective to least-selective" — with nuance: if you frequently sort by a column, including it can eliminate expensive sorts even when it isn't very selective.
Common interview shapes: order history (customer_id, order_date), event processing (status, priority, created_at), activity feeds (user_id, type, timestamp).
Covering indexes — answer the query from the index alone
An index that includes all columns a query needs — not just the ones it filters or sorts on — so the database can return results straight from the index without fetching each row's full data page.
-- Regular index
CREATE INDEX idx_user_time ON posts(user_id, created_at);
-- Covering index also stores the likes column
CREATE INDEX idx_user_time_likes ON posts(user_id, created_at) INCLUDE (likes);
Showing a feed with like counts, a regular index finds matching posts then fetches each post's page just to read likes. Including likes in the index skips those page lookups entirely — powerful for read-heavy features like feeds and leaderboards. The trade-off is size: covering indexes store extra columns.
Covering indexes are more niche than go-to. Modern query optimizers are smart with regular indexes, and covering indexes carry real maintenance and storage costs. In an interview, favor simpler strategies and only reach for a covering index with a clear justification. When unsure, err on the side of simplicity.
08 Choosing an Index — The Decision Flow
The instructor's confession: in a real interview you rarely go deep on any one index's implementation. What matters is spotting where queries are inefficient, which columns to index, and whether a special index is needed.
- Know when you need an index and on what columns — think about which columns you filter and sort on. This should be instinct when designing a schema.
- Default to B-trees — the Swiss-army knife, good at exact matches and ranges, the default in most databases for good reason.
- Two main exceptions: spatial data → geospatial index (learn geohashing at minimum); full-text search → inverted index (know the keyword→document mapping).
09 Active Recall
Answer from memory before expanding each. These test reconstruction and decision-making, not trivia.
Why is a full table scan so slow? Walk through it with numbers.
Data lives in ~8 KB pages of ~100 rows. Without an index the DB pulls each page into RAM and scans its rows until it finds the match. With 100M users that's ~1M pages; at ~100 µs per SSD→RAM round trip, a worst-case scan is ~100 seconds (prefetching cuts it to ~3–5s, but still far too slow for a single lookup).
What is an index, in one sentence, and how does it change the read pattern?
A data structure stored on disk that maps a value to the page it lives on. On a query you load the index, read off the target page number, and read only that page — a couple of reads instead of scanning ~1M pages.
Why are B-trees the default choice over hash indexes even for exact matches?
B-trees handle equality almost as fast as hash indexes but also support range queries and sorting because they keep data sorted. They're self-balancing, minimize disk I/O by matching page storage, and avoid performance cliffs under random writes. Hash indexes scatter similar values, so they can't do ranges or sorting at all.
What assumption makes an LSM tree fast for writes, and what does it cost?
It assumes writes vastly outnumber reads. Writes are batched in an in-memory memtable (plus a sequential WAL) and flushed as immutable sorted SSTables — turning random writes into large sequential ones. The cost is reads: a point query may check the memtable and many SSTables, mitigated by bloom filters and sparse indexes.
Why do two separate B-tree indexes on latitude and longitude fail for "restaurants within 5 miles"?
It's a 2D problem solved with two 1D indexes. The latitude index returns a global strip at that latitude; combining both indexes merges two huge sets into a rectangle far larger than the target circle, still needing a distance filter. You need an index that preserves 2D proximity.
Why can geohash reuse an ordinary B-tree? What breaks at grid boundaries?
Geohash encodes a 2D location as a 1D string where nearby points share a prefix, so proximity search becomes prefix range scans — exactly what B-trees do well. The edge case: two physically close points on opposite sides of a major grid division get different prefixes. Radius queries handle this by also scanning adjacent cells.
How does an R-tree differ from a quadtree, and why is it the production standard?
A quadtree splits space into fixed equal quadrants; an R-tree uses flexible, overlapping rectangles that adapt to data clusters and can index points and larger shapes in one structure. The trade-off is that overlap can force searching multiple branches, but implementations tune overlap-vs-depth for disk reads — hence its use in PostGIS/MySQL.
Why can't a B-tree serve WHERE content LIKE '%database%', and what fixes it?
B-trees sort strings lexicographically, so they only help prefix (or reversed-suffix) matches. A match anywhere inside the text forces a full scan. An inverted index — mapping each token to the documents containing it — fixes this; look up the word, get the documents.
Why does column order matter in a composite index?
The index is a B-tree keyed on a tuple, sorted by the first column, then the second. You can only use it efficiently for prefixes of the column list. (user_id, created_at) serves filtering on user_id (and sorting by created_at within it) but is useless for filtering on created_at alone.
Given a system-design prompt, how do you decide which index to use?
Confirm you need fast access on a large table. Then branch on data type: text → inverted index; lat/long → geospatial (geohash or R-tree); fast in-memory exact match → maybe hash (but often B-tree still wins); everything else → B-tree. Also identify inefficient queries and which columns to index.
10 Quick Revision
A compact refresh — not a replacement for the sections above.
The four index types
- B-tree — sorted, balanced. Exact + range. The default.
- Hash — O(1) exact match only. Rare; mostly in-memory (Redis).
- Geospatial — 2D lat/long. Geohash / quadtree / R-tree.
- Inverted — text search. Word → documents.
Must remember (numbers)
- Page ≈ 8 KB, ~100 rows.
- SSD→RAM ≈ 100 µs per page.
- 100M rows → ~1M pages → ~100s scan.
- B-tree lookup ≈ 2–3 page reads.
LSM in four stages
- Memtable (RAM, sorted) → WAL (durability)
- Flush → immutable SSTable (sequential)
- Compaction merges SSTables
- Reads use bloom filters + sparse indexes
Common mistakes to avoid
- Two 1D indexes for a 2D proximity query.
- Over-indexing write-heavy / tiny tables.
- Expecting a B-tree to serve
'%x%'. - Wrong composite column order.
- Over-selling hash indexes in interviews.
Important distinctions
- B-tree vs hash: ranges/sorting vs pure O(1).
- B-tree vs LSM: read-optimized vs write-optimized.
- Geohash vs R-tree: reuse B-tree vs adaptive rectangles.
- Composite vs covering: multi-column filter/sort vs return-from-index.