Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
- 12Relational Databases and Data Modeling for System Design
- 13Database Indexes and Query Performance: How to Make Reads Fast
- 14Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
- 15Database Replication: Primary-Replica, Read Replicas, Lag and Failover
- 16Database Sharding and Partitioning: How It Works and When You Need It
- 17SQL vs NoSQL: Data Models and How to Choose the Right Database
6 · Distributed Systems
- 18Why Distributed Systems Are Hard: Partial Failure, Timeouts and Clocks
- 19CAP Theorem and Consistency Models Explained Without the Myths
- 20Idempotency, Retries and Message Delivery Guarantees
- 21Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas
- 22Distributed Locks, Leases, Leader Election and Consensus
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
- 33Design a Notification System: Email, SMS and Push at Scale
- 34Design a Chat System Like a Messaging App
- 35Design a News Feed: Fan-Out, Ranking and the Celebrity Problem
- 36Design Search Autocomplete (Typeahead)
- 37Design a Distributed Job Scheduler
- 38Design a Ticket Booking System Without Overselling
- 39Design a Payment System: Idempotency, Ledgers and Reconciliation
11 · Large-Scale Case Studies
- 40Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming
- 41Design a Ride-Hailing System: Location, Matching and Real-Time Updates
- 42Design a Food Delivery Platform: Orders, Restaurants and Delivery Partners
- 43Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales
- 44Design a Web Search Engine: Crawling, Inverted Indexes and Ranking
12 · Interview Readiness
Lesson 13 · Databases
Database Indexes and Query Performance: How to Make Reads Fast
Why a query over millions of rows can take seconds or milliseconds depending on one index. Learn how B-tree indexes work, why they stay shallow, composite index column order, covering and partial indexes, selectivity, reading a query plan at a high level, why indexes are sometimes ignored, and what every index costs on writes.
For
SDE-1 candidates and developers who need to explain how to make a query fast, choose indexes for a data model, and understand the trade-off with write performance.
Level
Before you start
Read Relational Databases and Data Modeling (article 12), especially access patterns and the Pixly schema. Familiarity with binary search helps; the DSA course on this site covers it.
On this page
Key takeaways
- Without an index, a filtered query reads the whole table, so its cost grows with the data; a B-tree index finds rows in a handful of page reads even for billions of rows.
- In a composite index, put equality-filtered columns first and the range or sort column last; the index helps only queries that use its leading columns.
- Covering and partial indexes make hot queries cheaper still, and low-selectivity columns are poor index candidates on their own.
- Every index speeds some reads and slows every write that touches its columns, and costs storage; index for measured access patterns, not for every column.
- Read the query plan to confirm an index is used, and know the common reasons it is not: functions on columns, leading wildcards, type mismatches and the wrong column order.
Pixly's photos table passes 50 million rows. A user opens their profile, and the query "this user's photos, newest first" suddenly takes four seconds. Nothing in the code changed. The only difference is that the table grew. Add the right index and the same query takes a few milliseconds, whether the table has 50 million rows or 5 billion.
Indexes are the cheapest, most powerful performance tool in a database, and they are where you should look before reaching for caches, replicas or sharding. This article explains how they work, how to choose them, and what they cost.
What you will learn
#- What happens when a query has no index to use.
- What a B-tree index is, and why it stays fast as data grows.
- Composite indexes and why column order matters.
- Covering, partial and expression indexes.
- Selectivity: when an index is not worth using.
- How to read a query plan at a high level, and why indexes are sometimes ignored.
- What indexes cost on writes and storage.
- The order in which to try performance fixes.
Life without an index
#Without a suitable index, the database answers WHERE owner_id = 42 by reading every row and checking each one: a full table scan (PostgreSQL shows it as a "Seq Scan"). The work grows linearly with the table. That is fine for 2,000 rows and a disaster for 50 million, especially when the query runs hundreds of times per second, because each scan also pushes useful data out of memory.
The idea of an index is the same as the index at the back of a book, or the alphabetical order of your phone's contacts: keep the entries sorted by the thing you search for, so you can jump close to the answer instead of reading everything.
How a B-tree index works
#The default index type in most relational databases is the B-tree (strictly, usually a B+ tree). PostgreSQL's documentation lists it as the default among several index types (B-tree, hash, GiST, SP-GiST, GIN and BRIN), and describes it as handling equality and range queries on data that can be sorted (PostgreSQL: Indexes).
A B-tree is a balanced tree of pages (blocks of a few kilobytes). Each page holds many sorted keys. Inner pages point to child pages that cover ranges of keys; leaf pages hold the keys and a pointer to each row (or, in some databases, the row itself).
[ 1000 | 2000 | 3000 ] root page / | | \ [100|300|...] [1100|...] [2100|...] [3100|...] inner pages / | \ leaf: 1, 2, 3 ... 99 -> row pointers leaf pages (linked, in order)To find key 2,345, the database reads the root, follows the pointer for the 2,000 to 3,000 range, and so on down to a leaf. Because leaves are linked in order, a range (created_at between two dates) or an ordered scan (ORDER BY created_at) is a walk along adjacent leaves.
Why it stays shallow
#Each page holds hundreds of keys, so each level multiplies the reach by hundreds. That is why indexes stay fast as tables grow: the depth grows with the logarithm of the row count, and with a large base. The program below compares a full scan with binary search over a million sorted values (the same idea a B-tree uses inside each page), then computes how many levels a B-tree needs for a billion rows at two plausible fan-outs.
// File: IndexMath.javapublic class IndexMath { public static void main(String[] args) { int n = 1_000_000; int[] sorted = new int[n]; for (int i = 0; i < n; i++) { sorted[i] = i * 2; // even numbers, already in order } int target = 1_500_000; // sits at position 750,000 int linear = 0; for (int value : sorted) { linear++; if (value == target) { break; } } int binary = 0; int lo = 0; int hi = n - 1; while (lo <= hi) { binary++; int mid = (lo + hi) >>> 1; if (sorted[mid] == target) { break; } if (sorted[mid] < target) { lo = mid + 1; } else { hi = mid - 1; } } System.out.println("Full scan comparisons: " + linear); System.out.println("Binary search comparisons: " + binary); long rows = 1_000_000_000L; for (int fanout : new int[] {100, 300}) { int levels = 0; long reach = 1; while (reach < rows) { reach *= fanout; levels++; } System.out.println("B-tree, fanout " + fanout + ": " + levels + " levels for 1 billion rows"); } }}Full scan comparisons: 750001Binary search comparisons: 19B-tree, fanout 100: 5 levels for 1 billion rowsB-tree, fanout 300: 4 levels for 1 billion rowsSeven hundred and fifty thousand comparisons versus nineteen. And a B-tree over a billion rows needs only four or five page reads to reach any key, most of which (the upper levels) are almost always already in memory. The actual fan-out depends on key size and page size; the point is the shape of the numbers.
Other index types, briefly
#Type | Good for | Not good for |
|---|---|---|
B-tree | Equality, ranges, sorting, prefixes of composite keys | Full-text search, "contains" queries |
Hash | Equality only | Ranges, sorting |
Inverted (for example PostgreSQL GIN) | Full-text search, arrays, JSON containment | Simple equality on scalar columns (B-tree is better) |
Spatial (for example GiST, R-tree) | Geometry, "points within this area" | Ordinary lookups |
Block-range (for example BRIN) | Huge, naturally ordered tables such as append-only logs by time | Randomly ordered data |
For full-text search at scale, a dedicated search engine built on inverted indexes is usually better than the database (articles 36 and 44).
Composite indexes and column order
#A composite (multi-column) index sorts by the first column, then by the second within equal first values, and so on, like a phone book sorted by surname, then first name.
CREATE INDEX photos_by_owner ON photos (owner_id, created_at DESC);This index serves:
WHERE owner_id = 42(uses the first column);WHERE owner_id = 42 ORDER BY created_at DESC LIMIT 20(finds owner 42's entries, already in order: no sort needed);WHERE owner_id = 42 AND created_at < '2026-09-01'(equality then range).
It does not efficiently serve WHERE created_at > '2026-09-01' for all owners, because the index is not sorted by created_at overall; it would be like finding everyone named "Priya" in a phone book sorted by surname. PostgreSQL's documentation makes the same point: a multicolumn B-tree index is most efficient when there are constraints on the leading (leftmost) columns (PostgreSQL: Multicolumn Indexes).
The practical rule for ordering columns:
- Columns filtered by equality first (
owner_id = ?,status = ?). - Then the column used for a range or for sorting (
created_at). - Prefer more selective equality columns earlier when there are several, unless a query pattern needs a different prefix.
One composite index can replace several single-column ones: (owner_id, created_at) already serves "by owner", so a separate index on owner_id alone is redundant.
Covering, partial and expression indexes
#Covering indexes
#If the index contains every column the query needs, the database can answer from the index alone without visiting the table rows: an index-only scan. PostgreSQL, for example, lets you add non-key columns with INCLUDE:
CREATE INDEX photos_feed_cover ON photos (owner_id, created_at DESC) INCLUDE (photo_id, status);A query selecting only photo_id, status and created_at for an owner never touches the table. This is a powerful trick for the hottest queries, at the cost of a larger index.
Partial indexes
#Index only the rows you query:
CREATE INDEX orders_pending ON orders (created_at) WHERE status = 'pending';If 0.1% of orders are pending, this index is 1,000 times smaller than an index on all orders, and the "show pending orders" query reads only it. Article 12's library exercise used a partial unique index to enforce "one open loan per copy".
Expression indexes
#If queries search on a computed value, index that value:
CREATE INDEX users_email_lower ON users (lower(email));-- serves: WHERE lower(email) = lower('Asha@Example.com')Selectivity: when an index does not help
#Selectivity is how much an index narrows the search. An index on user_id is highly selective: each value matches a few rows. An index on a boolean like is_active, where 95% of rows are true, is not: "find active users" still touches almost every row, and reading through an index then jumping to each row is slower than simply scanning the table.
Database query planners use table statistics to estimate this, and will (correctly) ignore an index that would not help. Low-cardinality columns are useful as the first column of a composite index only if queries always filter on them and the remaining columns narrow things further, or as the condition of a partial index.
Reading a query plan
#Every relational database can tell you how it intends to run a query. In PostgreSQL, EXPLAIN shows the plan and EXPLAIN ANALYZE actually runs the query and reports real row counts and timings (PostgreSQL: Using EXPLAIN).
You do not need to master plans for system design, but you should recognise the main shapes. Here is an illustrative before-and-after for Pixly's profile query (simplified for teaching; real output contains costs, widths and timing details):
-- Before: no suitable indexLimit -> Sort (by created_at desc) -> Seq Scan on photos (filter: owner_id = 42) reads every row-- After: CREATE INDEX photos_by_owner ON photos (owner_id, created_at DESC)Limit -> Index Scan using photos_by_owner on photos (index condition: owner_id = 42) reads ~20 index entries and rowsPlan node | Meaning | Usually good when |
|---|---|---|
Sequential scan | Read the whole table | The table is small, or the query needs most of it |
Index scan | Walk the index, fetch matching rows | Few rows match |
Index-only scan | Answer from the index alone | The index covers the query |
Bitmap scan | Collect matching row locations from an index, then read them in physical order | A moderate number of rows match |
Sort | Sort rows in memory or on disk | Small inputs; avoid for big ones by using an index already in order |
Why an index is sometimes not used
#A classic interview and production puzzle. Common causes:
Cause | Example | Fix |
|---|---|---|
Function or calculation on the column |
| Expression index, or store normalised values |
Leading wildcard |
| A search engine or trigram index; |
Type mismatch | Comparing a text column to a number forces a conversion | Use matching types |
Wrong column order | Index | Reorder, or add the right index |
Low selectivity | Index on a mostly-true boolean | Partial index or composite index |
|
| Rewrite as |
Stale statistics | Planner thinks the table is tiny | Update statistics (for example |
What indexes cost
#Indexes are not free. Every INSERT must add an entry to every index on the table; every UPDATE of an indexed column must move entries; every DELETE must remove them. Each index also takes disk space and memory, and competes for the database's cache.
A table with 6 indexes does roughly 7 structure updates per inserted row (the table plus 6 indexes). For Pixly's likes table at 1,700 inserts per second at peak, adding an unneeded index adds 1,700 extra index writes per second. On write-heavy tables, every index must earn its place.
More indexes | Fewer indexes | |
|---|---|---|
Reads | Faster, more query shapes covered | Some queries scan |
Writes | Slower (write amplification) | Faster |
Storage and memory | More | Less |
Maintenance | Bloat, more to rebuild | Less |
Guidelines:
- Index for measured or clearly expected access patterns, not for every column.
- Remove indexes nobody uses (most databases can report index usage).
- Keep write-heavy tables lean; move heavy read patterns to replicas, caches or a separate read model (article 27).
Building indexes on live tables
#Creating an index on a large table can take minutes or hours, and some methods block writes while they run. Use the database's online option where available (PostgreSQL has CREATE INDEX CONCURRENTLY, which avoids blocking writes at the cost of a slower build), and create indexes during quieter periods. A missing index that is discovered at peak is a classic outage: the fix (building it) can itself be slow and risky.
The order of performance fixes
#When a query is slow, try fixes roughly in this order, cheapest first:
- Fix the query: select only needed columns, remove N+1 patterns, avoid functions on indexed columns.
- Add or adjust an index, verified with the query plan.
- Denormalise or precompute the hot read path (article 12).
- Cache the result (article 09).
- Read replicas for read volume (article 15).
- Partition or shard when data or write volume outgrows one machine (article 16).
Each step down adds more complexity. Interviewers notice when a candidate jumps straight to step 6.
Worked example: indexing Pixly
#Access pattern | Query shape | Index |
|---|---|---|
Photo by id |
| Primary key |
A user's photos, newest first |
|
|
Cursor page of the above |
| Same index, with |
Has user X liked photo Y? |
| Primary key |
Photos a user liked |
|
|
Who follows me |
|
|
Photos stuck in processing |
| Partial index on |
Notice the "only if this feature exists" note on the likes index. likes is Pixly's most write-heavy table. Every extra index there costs thousands of writes per second at peak, so it is added only when a real access pattern needs it.
Failure modes
#Index problems rarely announce themselves. They show up as a system that was fine last month and is slow today.
Failure | What you see | Mitigation |
|---|---|---|
Missing index, discovered as data grows | A query's latency rises steadily with table size; database CPU and disk reads climb; eventually timeouts | Review query plans for hot queries; alert on slow queries; load-test with production-sized data |
Index build blocks writes | Writes stall for the duration of the build | Online index builds; build off-peak |
Plan regression after statistics change | A query that used an index suddenly scans | Keep statistics fresh; monitor slow-query logs after deployments and large data changes |
Too many indexes on a write-heavy table | Insert latency rises; replication lag grows | Remove unused indexes; move read patterns elsewhere |
Index and table bloat over time | Indexes grow larger than the data justifies; more memory pressure | Routine maintenance; monitor index size |
Trade-offs
#Decision | You gain | You pay |
|---|---|---|
Add an index | Fast reads for its query shapes | Slower writes, storage, memory |
Composite instead of several single-column indexes | One structure serves several queries, sorted results | Must match the column order to the queries |
Covering index | Index-only reads | Larger index |
Partial index | Small, targeted index | Only helps queries matching its condition |
Search engine instead of database text search | Relevance, fuzzy matching, scale | Another system, eventual consistency |
Common mistakes
#- Indexing every column "just in case".
- Composite indexes with the range column first.
- Assuming an index is used without checking the plan.
- Indexing low-selectivity flags on their own.
- Forgetting that indexes slow writes on write-heavy tables.
- Building a large index on a busy table with a blocking method.
- Reaching for caching or sharding before fixing a missing index.
In an interview
#- After listing access patterns, name the index for each hot one, with the column order and why.
- Mention that cursor pagination relies on the same index.
- For write-heavy tables, say you are deliberately keeping indexes minimal.
- If asked "the query is slow, what do you do?", answer in the order above, starting with the query plan.
Exercises
#Exercise 1
Column order
#A support-ticket table is queried in three ways:
WHERE team_id = ? AND status = 'open' ORDER BY created_atWHERE team_id = ? ORDER BY created_at DESC LIMIT 50WHERE assignee_id = ? AND status = 'open'
Propose the smallest set of indexes that serves all three well, with column order and reasons.
Exercise 2: Why is it slow?
#Each query has an index that seems relevant but is not used. Explain why and fix it.
- Index on
orders(created_at); queryWHERE DATE(created_at) = '2026-10-01'. - Index on
users(phone)wherephoneis a text column; queryWHERE phone = 9876543210(a number). - Index on
products(name); queryWHERE name LIKE '%phone%'. - Index on
(created_at, customer_id); queryWHERE customer_id = 77 ORDER BY created_at DESC.
Exercise 3: Write amplification
#An events table receives 5,000 inserts per second and has 8 indexes. A teammate proposes 3 more to speed up an internal report that runs once a day. Estimate the extra index writes per day, and suggest an alternative.
Exercise 4: Design the indexes
#A ride-hailing app stores trips: trip_id, rider_id, driver_id, status, city_id, requested_at, completed_at, fare. Access patterns: a rider's trip history, newest first (frequent); a driver's trips this week (frequent); active trips (status requested or on_trip) in a city for operations dashboards (frequent, small result); a monthly revenue report by city (rare). Propose indexes and explain what you would not index.
Show solutionHide solution
- Index A:
(team_id, status, created_at)serves query 1 directly: equality onteam_idandstatus, then rows already increated_atorder. - Query 2 filters only on
team_idand orders bycreated_at. Index A's leading column isteam_id, butstatussits between it andcreated_at, so rows for one team are not increated_atorder overall. Index B:(team_id, created_at DESC)serves query 2. - Index C:
(assignee_id, status)serves query 3. If open tickets are a small fraction, a partial index(assignee_id) WHERE status = 'open'is smaller and better.
Three indexes. You could drop A if query 1 is rare (index B narrows to the team, and filtering by status among one team's recent tickets may be cheap enough), but for a frequent "open tickets" queue, A is worth it.
Exercise 2
Show solutionHide solution
- The function
DATE(created_at)hides the column from the index. Rewrite as a range:WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02', which uses the index. - Comparing text to a number forces a type conversion on every row, so the index on the text value cannot be used. Pass the value as text (
'9876543210'), which is also correct for phone numbers (leading zeros, plus signs). - A leading wildcard cannot use a B-tree, which is sorted by the beginning of the string. Use a full-text or trigram index, or a search engine, if "contains" search is a real requirement.
- The index leads with
created_at, so rows for one customer are scattered across it. Create(customer_id, created_at DESC).
Exercise 3
Show solutionHide solution
Three more indexes add 3 index writes per insert: 5,000 x 3 = 15,000 extra index writes per second, about 1.3 billion per day (15,000 x 86,400 = 1,296,000,000), to speed up one query per day. That also adds storage, memory pressure and replication volume.
Alternatives: run the report on a read replica or in an analytics store (for example, export events to a data warehouse or columnar store, which is built for this kind of query); precompute the daily figures incrementally with a small aggregation job; or accept a slower report since it runs once a day.
Exercise 4
Show solutionHide solution
- Rider history:
(rider_id, requested_at DESC), which also supports cursor pagination. - Driver's trips this week:
(driver_id, requested_at DESC); the range onrequested_atfollows the equality ondriver_id. - Active trips in a city: a partial index
(city_id) WHERE status IN ('requested', 'on_trip'), or(city_id, status)partial on active statuses. Active trips are a tiny fraction of all trips, so the partial index stays small and hot. In practice, live operational views often come from a separate real-time store fed by trip events (article 41), but this index makes the database version workable. - Monthly revenue by city: do not index for it on the primary. Run it on a replica or an analytics store, or keep a daily aggregate table updated by a job.
Not indexed: fare, completed_at and status on their own. Trips are written and updated constantly (status changes), so each extra index costs on every state change.
Where to go next
#- Next: Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions, where many fast queries meet at the same rows.
- Background: binary search is explained step by step in Searching and Sorting: Choose the Right Approach.
- Later: when one machine's indexes are no longer enough, replication (article 15) and sharding (article 16) take over.
Sources & further reading
- Technology
- Software Engineering
- #databases
- #distributed systems
- #performance
- #roadmap
- #scalability
- #SQL
- #system design
- #system design interview
Connected lessons
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
SQL and PostgreSQL Fundamentals for Java Developers
Guide · Technology
Inheritance, Overriding and Overloading
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

