Skip to content
SparkTime, home
System Design From Zero to Interview-Ready
Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
6 · Distributed Systems
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
11 · Large-Scale Case Studies
12 · Interview Readiness
Lesson 13 of 45

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.

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

Code
                     [ 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.

Java
// 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");        }    }}
Code
Full scan comparisons:     750001Binary search comparisons: 19B-tree, fanout 100: 5 levels for 1 billion rowsB-tree, fanout 300: 4 levels for 1 billion rows

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

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

  1. Columns filtered by equality first (owner_id = ?, status = ?).
  2. Then the column used for a range or for sorting (created_at).
  3. 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:

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

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

SQL
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):

Code
-- 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 rows

Plan 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

WHERE lower(email) = ... with an index on email

Expression index, or store normalised values

Leading wildcard

WHERE name LIKE '%kumar'

A search engine or trigram index; 'kumar%' can use a B-tree

Type mismatch

Comparing a text column to a number forces a conversion

Use matching types

Wrong column order

Index (created_at, owner_id) for WHERE owner_id = ?

Reorder, or add the right index

Low selectivity

Index on a mostly-true boolean

Partial index or composite index

OR across different columns

WHERE owner_id = 1 OR caption = 'x'

Rewrite as UNION, or index both

Stale statistics

Planner thinks the table is tiny

Update statistics (for example ANALYZE)

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:

  1. Fix the query: select only needed columns, remove N+1 patterns, avoid functions on indexed columns.
  2. Add or adjust an index, verified with the query plan.
  3. Denormalise or precompute the hot read path (article 12).
  4. Cache the result (article 09).
  5. Read replicas for read volume (article 15).
  6. 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

WHERE photo_id = ?

Primary key

A user's photos, newest first

WHERE owner_id = ? ORDER BY created_at DESC LIMIT 20

(owner_id, created_at DESC), optionally covering

Cursor page of the above

... AND (created_at, photo_id) < (?, ?)

Same index, with photo_id as a tie-breaker column

Has user X liked photo Y?

WHERE photo_id = ? AND user_id = ?

Primary key (photo_id, user_id)

Photos a user liked

WHERE user_id = ? ORDER BY created_at DESC

(user_id, created_at DESC) on likes, only if this feature exists

Who follows me

WHERE followee_id = ?

(followee_id, follower_id) on follows

Photos stuck in processing

WHERE status = 'processing' AND created_at < ?

Partial index on created_at where status is processing

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:

  1. WHERE team_id = ? AND status = 'open' ORDER BY created_at
  2. WHERE team_id = ? ORDER BY created_at DESC LIMIT 50
  3. WHERE 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.

  1. Index on orders(created_at); query WHERE DATE(created_at) = '2026-10-01'.
  2. Index on users(phone) where phone is a text column; query WHERE phone = 9876543210 (a number).
  3. Index on products(name); query WHERE name LIKE '%phone%'.
  4. Index on (created_at, customer_id); query WHERE 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 on team_id and status, then rows already in created_at order.
  • Query 2 filters only on team_id and orders by created_at. Index A's leading column is team_id, but status sits between it and created_at, so rows for one team are not in created_at order 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
  1. 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.
  2. 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).
  3. 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.
  4. 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 on requested_at follows the equality on driver_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

#

Sources & further reading

  1. postgresql.org
  2. postgresql.org
  3. postgresql.org
  • Guide · Technology

    SQL and PostgreSQL Fundamentals for Java Developers

  • Guide · Technology

    Inheritance, Overriding and Overloading

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages