05 · Databases

Indexes

8 min read · 5 questions · 1 exercise

An index is a sorted copy of one or more columns, kept as a shallow tree of pages, that lets a query reach its rows in a few page reads instead of reading the whole table.

A customer opens their order history and waits long enough to wonder whether the page is broken. Behind it is one query, SELECT * FROM orders WHERE customer_id = 4217, which returns the nine orders that customer has placed. To find them, the database read all two million rows in the table, checked each one’s customer_id, and threw 1,999,991 away. It will do the same for the next customer.

Nothing is wrong with the query or the server. The database has no way to know where customer 4217’s rows are, so the only correct plan is to look at all of them. An index gives it a way: a separate structure, sorted by the column you search on and kept up to date on every write, that leads straight to the matching rows. It is the index at the back of a printed book: terms in order, each with its pages, so nobody reads the book to find one word.

The full scan

Without an index, filtering on customer_id is a full table scan: read every page of the table, test every row, keep the matches. A page is the fixed-size block a database reads and writes (8 KB by default in PostgreSQL, 16 KB in MySQL’s InnoDB). At 100 orders to a page, two million orders is 20,000 pages, and the scan reads all of them to return nine rows.

That is O(n) where n is the size of the table, not the size of the answer. A customer with nine orders costs as much to find as one with nine thousand, and both get slower as the table grows. Nor can the scan stop at nine: nothing tells it there is not a tenth on the last page.

Sorted, shallow and wide

Almost every index you will create is a B-tree. Its leaves hold the indexed values in sorted order, each with a way to reach its row, and each leaf page is linked to the next. Above them sit branch pages whose entries are boundaries (keys below this one are in that child), and above those, one root page. A lookup starts at the root, picks the child whose range holds the value, reads it, and repeats until it reaches a leaf: one page per level.

There are few levels because each page is wide. Say 500 entries to a page. Then 1,000,000 keys fill 2,000 leaves, those need 4 branch pages, and those fit under one root: three page reads per lookup. Three levels at that width hold up to 125 million keys (500 × 500 × 500), and a fourth holds 62.5 billion. A binary tree needs 20 levels for the same million keys, since 2¹⁹ is 524,288 and 2²⁰ is 1,048,576. Fetching a page is the expensive step, and comparing against a few hundred keys once it is in memory is cheap, so the tree spends comparisons to save fetches. The root and branches, read by every lookup, tend to stay cached.

The scene draws a small table the way InnoDB stores every table: a B-tree on the primary key with the rows themselves in the leaves, twenty orders to a page. PostgreSQL keeps rows in a separate heap, so its lookups read one more page to fetch the row. Each page is labelled with the ids beneath it and has two children, so the tree fits on a phone. Three queries use the tree; the fourth searches customer_id, which has no index.

Predict first

80 orders sit in a B-tree on id, 20 to a leaf, under two branch pages and a root. The scene runs id = 42, id = 7, the ids 35 to 45, and then customer_id = 9, which has no index. How many page reads do the badges count for id = 42, and for the customer_id search?

  1. An index does not point straight at the row from nowhere. The lookup has to find the right leaf first, and it does that by reading one page per level: the root, the 41–80 branch, the 41–60 leaf. And 80 is the number of rows the scan checks, not the pages it reads.

  2. Three is right for id = 42. But a scan pays for pages, not rows: the 80 orders live 20 to a page, so reading all of them is 4 page reads, each one checking 20 rows.

  3. That would need the tree to be sorted by customer_id. It is sorted by id, so customer 9's orders (12 and 57) could be on any page, and the only way to find them all is to read every leaf.

  4. The lookup reads one page per level: root, 41–80, then the 41–60 leaf that holds row 42. The customer search reads every leaf, first to last, all 80 rows, because nothing in a tree sorted by id says where customer 9's orders are.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

One page per level, or every page

one query · a page’s badge counts its reads · amber = a full scan

query
root
1–40
41–80
1–20
21–40
41–60
61–80

Eighty orders sorted by id, twenty to a leaf page — use Next step to go one at a time.

Four pages against three looks like a close race, and at eighty rows it is. The badges show which number grows: a lookup pays one page per level, a scan one per page of table. At the opening’s two million orders, 100 to a leaf and 500 entries to a branch page, the tree is 20,000 leaves under 40 branch pages under one root. A lookup by id still reads three pages; the scan reads 20,000.

The range did something a hash table cannot. It descended once, to where 35 would sit, then read sideways along the leaves, because neighbouring keys sit on neighbouring pages. A hash table finds one exact key in O(1) by scattering keys on purpose, so it has no idea where 36 is relative to 35, and that rules out ranges such as BETWEEN and rows returned already sorted for an ORDER BY. It is why PostgreSQL’s CREATE INDEX builds a B-tree unless told otherwise, and why its hash indexes serve equality alone.

Composite indexes and the leftmost prefix

An index can cover several columns, and it sorts by them in the order you list them. An index on (customer_id, created_at) is sorted by customer and, inside each customer, by date: (4216, 2026-03-02), then customer 4217’s nine orders from (4217, 2025-11-30) to (4217, 2026-05-01), then (4218, 2025-08-09).

So customer 4217’s entries form one run, in date order. WHERE customer_id = 4217 descends to the start of the run and reads it; adding AND created_at >= '2026-01-01' descends to the first matching date inside it; ORDER BY created_at DESC LIMIT 20 reads the run backwards and stops after twenty, with no sort. But the index cannot take WHERE created_at >= '2026-01-01' straight to its rows, because this year’s orders are spread through every customer’s run, the way customer 9’s orders were spread across the scene’s pages. An index serves a query that pins down a leftmost prefix of its columns, not one that skips the first.

So put the columns a query tests for equality first, and the one it ranges or sorts over last. Some planners can skip-scan an index whose first column has few distinct values; treat that as a bonus to notice in a plan, not a design.

What an index costs

An index is not free to keep. Every INSERT adds an entry to every index on the table, every DELETE removes one, and an UPDATE to an indexed column moves its entry. A table with five indexes makes six writes per inserted row, and each index takes disk space and a share of the memory the table’s pages compete for. Index the columns your queries filter, join and sort by; an index no query uses is write cost and nothing else.

Having an index does not mean the planner will use it, and two reasons cover most surprises. One is selectivity. If WHERE status = 'delivered' matches 90% of the table, going through the index means fetching nearly every row from its own page, which is more work than reading the table straight through, and the planner, which keeps statistics on each column, picks the scan. An index pays off when a query keeps a small fraction of the rows. The other is a function on the column. An index on email is sorted by email, so WHERE lower(email) = 'kim@example.com' gives the planner nothing to descend by; most engines let you index the expression lower(email) instead. Either way, EXPLAIN shows the plan the database chose, which beats guessing.

Here is the opening’s fix, with queries the new index can and cannot take straight to their rows:

-- The order-history page: one customer's orders, newest first.
SELECT id, total, created_at
FROM orders
WHERE customer_id = 4217
ORDER BY created_at DESC
LIMIT 20;

-- The equality column first, then the column the page sorts by.
CREATE INDEX orders_customer_created
  ON orders (customer_id, created_at);

-- Leftmost prefixes: the index takes both straight to their rows.
SELECT id FROM orders WHERE customer_id = 4217;
SELECT id FROM orders
WHERE customer_id = 4217 AND created_at >= '2026-01-01';

-- Not a prefix: dates are in order only inside each customer's run.
SELECT id FROM orders WHERE created_at >= '2026-01-01';

-- An index on email is sorted by email, not by lower(email)...
CREATE INDEX customers_email ON customers (email);
SELECT id FROM customers WHERE lower(email) = 'kim@example.com';
-- ...so index the expression the query actually compares.
CREATE INDEX customers_email_lower ON customers (lower(email));

One index serves the page and both prefix queries, and the column order is what does it. Reversed, as (created_at, customer_id), it would serve the date-only query instead and make every question about one customer wade through every date. For the page, the database descends to the end of customer 4217’s run, reads up to twenty entries backwards and fetches those rows, sorting nothing.

Try it

Walk the index yourself

Underneath, the index on (customer_id, created_at) is a sorted list of entries. Model it as an array of IndexEntry, sorted by customerId and then by createdAt, and write ordersSince(index, customerId, since), which answers the second prefix query above. Binary-search for the first entry that does not sort before (customerId, since), read forward while entries still belong to that customer, and count every entry you look at. On an index of 100,000 entries it should look at fewer than 25, where a scan looks at all 100,000.

interface IndexEntry {
  customerId: number;
  createdAt: string; // 'YYYY-MM-DD', so string order is date order
  orderId: number;
}
Hint

Search for a position, not a match: there may be no order dated since itself. The comparison is the index’s own sort, customer first and date second, so an entry sorts before the target when its customer is smaller, or when the customer is the same and its date is earlier. Then decide which entry tells the forward walk to stop.

Solution
function ordersSince(
  index: IndexEntry[],
  customerId: number,
  since: string,
): { orderIds: number[]; examined: number } {
  let examined = 0;
  let lo = 0;
  let hi = index.length;
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    const entry = index[mid]!;
    examined += 1;
    const before =
      entry.customerId < customerId ||
      (entry.customerId === customerId && entry.createdAt < since);
    if (before) lo = mid + 1;
    else hi = mid;
  }
  const orderIds: number[] = [];
  for (let i = lo; i < index.length; i += 1) {
    examined += 1;
    if (index[i]!.customerId !== customerId) break;
    orderIds.push(index[i]!.orderId);
  }
  return { orderIds, examined };
}

The while loop is the descent with every page shrunk to a single entry: each comparison halves what is left, so 100,000 entries take 17 comparisons here, where a B-tree makes the same cut in a few page reads, each settling hundreds of entries at once. The for loop is the walk along the leaves, and the entry that stops it, the first one for customer 4218, is the leftmost-prefix rule in code. One customer’s entries are contiguous only because the customer comes first in the sort. Search on since alone and there is no single place to start.

To see it work, on 10,000 customers with one order a month from July 2025 to April 2026:

const index: IndexEntry[] = [];
for (let customerId = 1; customerId <= 10_000; customerId += 1) {
  for (let month = 0; month < 10; month += 1) {
    const day = new Date(Date.UTC(2025, 6 + month, 1));
    index.push({
      customerId,
      createdAt: day.toISOString().slice(0, 10),
      orderId: index.length + 1,
    });
  }
}

const { orderIds, examined } = ordersSince(index, 4217, '2026-01-01');
console.log(orderIds, examined); // [ 42167, 42168, 42169, 42170 ] 22

Four orders, January to April, from 22 entries: 17 to find the start, then 4 matches and the one entry that ends the run.

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

An index on orders.id holds 400 entries to a page at every level. The table grows from 1,000,000 orders to 50,000,000. How many more pages does a lookup by id read?

  1. That is how a full scan grows, one page read per page of table. A lookup reads one page per level, and levels grow far more slowly than rows.

  2. At 1,000,000 keys: 2,500 leaves, 7 branch pages, one root. At 50,000,000: 125,000 leaves, 313 branch pages, still one root, because 313 fits in a page of 400. Three levels hold up to 400³ = 64,000,000 keys, so the table grew fiftyfold and the lookup did not change.

  3. One extra level per doubling is how a binary tree grows, and 50 times is between 2⁵ and 2⁶. A B-tree level multiplies what the tree can hold by 400, not by 2.

  4. A level is added only when the keys outgrow what the levels above can point to. Three levels at 400 a page reach 64,000,000 keys, and 50,000,000 is under that.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

The order-history page runs WHERE customer_id = ? ORDER BY created_at DESC LIMIT 20. Which index lets the database read just the 20 newest entries for that customer, with no sort?

  1. Sorted by date first, so one customer's orders are scattered through the whole index. Walking it newest first means passing every other customer's recent orders until twenty of this one's turn up.

  2. It finds all of the customer's orders, but not in date order, so every one of them has to be fetched and sorted before the newest 20 are known. For a customer with thousands of orders that is thousands of rows for a page of twenty.

  3. Two indexes are two separate trees. A planner can use one of them, and some engines can combine their matches, but neither keeps one customer's orders in date order, so the newest 20 still need a sort.

  4. Equality column first, sort column second. The customer's entries form one run already in date order, so the database descends to the end of the run and reads twenty entries backwards.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

orders has 2,000,000 rows at 100 to a page and an index on status. 90% of orders are 'delivered'. EXPLAIN shows WHERE status = 'delivered' running as a full table scan. Why?

  1. The query keeps 1,800,000 rows. Fetched through the index, each can need its own page read, up to 1,800,000 of them, and even an engine that groups those fetches by page ends up reading nearly every page and the index on top. The scan reads the 20,000 pages once, in order. The planner estimates this from its statistics and picks the scan.

  2. A corrupt index shows up as errors or wrong answers, not as a plan choice. Run the same query for a status held by a small share of rows and the planner would most likely use the index.

  3. Uniqueness is not the requirement; the share of rows a query keeps is. An index on status serves a status that only a few rows have.

  4. A B-tree sorts text as readily as numbers. Indexes on email and other text columns are among the most used in any schema.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

An events table takes 5,000 inserts a second and has 6 indexes. Four of them have not been used by any query in months, and writes are falling behind. What does dropping those four change?

  1. Unused by reads is not unused by writes. Every insert adds an entry to every index, whether or not any query ever reads it.

  2. Only a query that used an index can lose it, and these four have not been used in months. Check the database's index-usage statistics over a window long enough to include rare jobs, such as a monthly report, before dropping one.

  3. Today each insert writes the row plus 6 index entries, 7 structures, or 35,000 a second. With two indexes left it is the row plus 2, which is 15,000 a second.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Key takeaways

  • Without an index, finding rows by a column means reading every page of the table, so the cost grows with the table rather than with the answer.

  • A B-tree index is sorted, wide and shallow: a lookup reads one page per level, and at a few hundred entries a page, three levels already reach a hundred million rows.

  • A composite index serves queries that pin down a leftmost prefix of its columns, so put equality columns first and the range or sort column last.

  • Every index is paid for on every write and is skipped when a query keeps most of the rows or wraps the column in a function, so index for the queries you actually run and confirm the plan with EXPLAIN.