◐ ai-teacher

🗄 SQL & Databases · Chapter 7

Indexes: when they help, when they cost

An index is a second copy of your data, sorted differently. That sentence explains both why reads get faster and why writes get slower.

Study this lesson free in the app →

The basics

An index is a separate, sorted copy of one or more columns, with a pointer from each entry back to the full row, so the database can jump straight to matching rows instead of reading the whole table.

The problem it solves: finding one ride in a 40-million-row table without help means reading all 40 million rows. With an index on ride_id, the database walks a shallow tree — 3 or 4 steps — and lands on the row in milliseconds. That's the difference between a payments API that answers in 5 ms and one that times out.

The mental model: the index at the back of a textbook. Sorted alphabetically, so you jump to "Kafka" instead of reading 900 pages. But somebody had to build it, it takes extra pages, and every time a chapter is added it must be updated. Databases pay exactly those costs: every insert also writes into every index.

Words you must know 5

B-tree
the default index structure: a sorted, shallow tree (~4 levels for 100M rows).
Selectivity
what fraction of rows a filter keeps. Indexes help when it's small.
Composite index
an index on several columns, sorted by the first, then the second.
Leftmost prefix
a composite index on (a, b) helps filters on a, or a and b — never b alone.
Write amplification
each extra index adds one more write to every insert.

Explained

SELECT * FROM rides WHERE ride_id = 'ride_9001';

Without an index: read 40 million rows, keep one. Seconds. With an index on ride_id: three or four jumps down a tree. Milliseconds.

So index everything?

No — because every index must be updated on every insert, update and delete, and it occupies disk. Ten indexes on a hot table can halve your write throughput. Indexes are a trade, and the whole skill is knowing which side you're on.

The index at the back of a book

To find every mention of Kafka in a 900-page book, you can read all 900 pages, or turn to the index and find "Kafka — 210, 344, 501".

The index works because it's sorted alphabetically — you don't read it either, you jump.

But notice what it cost. Somebody built it. It takes up 30 extra pages. And if the author inserts a new chapter, every page number in the index is wrong and it must be rebuilt.

That's a database index exactly. A separate, sorted structure that makes lookups fast, costs storage, and must be maintained on every change to the underlying book.

How a B-tree actually answers a query

The default index type nearly everywhere is a B-tree — a shallow, sorted tree.

                [ M ]                     ← root
           /            \
      [ D  H ]        [ R  W ]            ← internal nodes
      /   |   \       /   |   \
   [A-C][E-G][I-L] [N-Q][S-V][X-Z]        ← leaves → row pointers

Finding a value means comparing at each level and descending. A tree over 100 million rows is about 4 levels deep, so a lookup is roughly 4 page reads instead of 100 million.

Because the leaves are sorted and linked, a B-tree serves more than equality:

WHERE ride_id = 'ride_9001'              -- ✅ point lookup
WHERE fare BETWEEN 100 AND 500           -- ✅ range: find the start, walk
WHERE ride_date >= '2026-07-01'          -- ✅ range
ORDER BY ride_date                       -- ✅ already sorted, no sort step
WHERE city LIKE 'Shil%'                  -- ✅ prefix match
WHERE city LIKE '%long'                  -- ❌ can't — no known starting point

That last pair is the key intuition: an index is sorted, so anything that gives it a starting point works, and anything that doesn't requires reading everything anyway.

Step through a lookup

The order of columns in a composite index is not cosmetic — it's the order the tree is sorted in.

Why does only one of these use the index?

There is one index: CREATE INDEX ON rides (city, ride_date);

-- A
WHERE city = 'Shillong' AND ride_date = '2026-07-15'
-- B
WHERE city = 'Shillong'
-- C
WHERE ride_date = '2026-07-15'
-- D
WHERE lower(city) = 'shillong'
-- E
WHERE city = 'Shillong' AND fare > 100

The costs, stated plainly

Write amplification. Every INSERT writes the row plus one entry per index. A table with 8 indexes does 9 writes per insert. On a high-throughput ingestion table this is often the actual bottleneck.

Storage. An index on a wide column can approach the size of the table. Ten indexes can exceed it.

Maintenance. Updates fragment the tree; it needs periodic rebuilding. Statistics go stale and the planner starts choosing badly.

When an index doesn't help at all:

The index that made everything slower

An ingestion table takes 40,000 inserts a second. Analysts complain their ad-hoc queries are slow, so eleven indexes get added over six months — one per column somebody filtered on.

Three months later, ingestion starts falling behind and consumer lag climbs every evening.

Each insert now performs 12 writes: the row plus eleven index entries. The table has become write-bound, and the cause is invisible in application code — nothing changed there.

Worse, most of those indexes are barely used. Two serve real queries; nine were added for a question asked once.

What should have happened: the analysts' queries didn't belong on the operational table at all. That's the OLTP/OLAP split — ad-hoc analysis belongs in the warehouse, where columnar storage answers those questions without indexes and without touching the write path.

Diagnosing this on a real system:

-- Postgres: which indexes are actually used?
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0;      -- never used since stats were reset

Unused indexes are pure cost. Auditing them is one of the highest-return, least-glamorous performance tasks there is.

An index is a sorted copy. Reads jump; writes pay for every index.

Leftmost prefix: an index on (a, b) serves a and a AND b, never b alone.

A function on the column kills the index. year(date) = 2026 ❌ → date >= … AND date < … ✅

Words to know

B-treeThe default index structure — a shallow sorted tree, ~4 levels deep over 100M rows.
SelectivityWhat fraction of rows a filter keeps. Low selectivity makes an index useless.
Composite indexAn index on several columns, sorted by the first, then the second, and so on.
Leftmost prefixAn index on (a, b) serves queries on a, or a and b — never b alone.
Covering indexOne containing every column a query needs, so the table is never touched.
Index-only scanAnswering a query entirely from the index.
Write amplificationOne insert becoming many writes — the row plus one entry per index.
Functional indexAn index on an expression, e.g. lower(city), for queries that must use that function.
Clustering keyThe warehouse equivalent — controls physical data layout for pruning, not a B-tree.

In two minutes

Test yourself

1. Why does WHERE year(ride_date) = 2026 ignore an index on ride_date?

  1. year() is a slow function
  2. The index stores raw dates, so the engine must compute year() on every row — a full scan
  3. Indexes don't work on dates
  4. It needs a composite index
Show the answer

The index stores raw dates, so the engine must compute year() on every row — a full scan — Wrapping the column in a function makes the stored sort order unusable. Rewrite as a half-open range instead.

2. An index on (city, ride_date). Which query cannot use it?

  1. WHERE city = 'Shillong'
  2. WHERE ride_date = '2026-07-15'
  3. WHERE city = 'Shillong' AND ride_date = '2026-07-15'
  4. WHERE city = 'Shillong' AND fare > 100
Show the answer

WHERE ride_date = '2026-07-15' — Leftmost prefix rule. Filtering only the second column is like finding everyone named Priya in a book sorted by surname.

3. When is a sequential scan legitimately faster than an index?

  1. Never
  2. When the filter is unselective — the index means jump-and-fetch for most of the table anyway
  3. Only on empty tables
  4. When there are no joins
Show the answer

When the filter is unselective — the index means jump-and-fetch for most of the table anyway — Below roughly 10-20% selectivity, sequential reads beat random ones. The planner knows this, which is why it sometimes ignores your index.

4. What is the cost of adding an eleventh index to a high-throughput table?

  1. Only disk space
  2. Every insert now performs an extra write — indexes tax the write path forever
  3. Nothing measurable
  4. Slower reads
Show the answer

Every insert now performs an extra write — indexes tax the write path forever — Write amplification. A table with 11 indexes does 12 writes per insert, which can become the ingestion bottleneck.

5. How do you make queries fast in Snowflake or BigQuery?

  1. Add B-tree indexes
  2. Partitioning, clustering keys and pruning — they have no B-tree indexes
  3. More memory
  4. Smaller tables
Show the answer

Partitioning, clustering keys and pruning — they have no B-tree indexes — Columnar warehouses skip data by pruning micro-partitions and columns. Proposing 'add an index' conflates OLTP with OLAP.

6. Roughly how deep is a B-tree index over 100 million rows?

  1. About 27 levels — log₂ of 100M
  2. About 100 levels
  3. 3–4 levels, because each node holds hundreds of keys
  4. One level per million rows
Show the answer

3–4 levels, because each node holds hundreds of keys — B-tree nodes are disk pages holding hundreds of keys each, so the fan-out is huge: 500⁴ is over 60 billion. log₂ would apply to a binary tree, which databases don't use for this reason.

There are 9 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →