SYSTEM DESIGN · CONCEPTS · DATABASES · EPISODE 17

Indexes: Why a Query Is Fast or Slow

What a database does without an index (a full scan), what a B-tree index is and why it makes lookups logarithmic, composite indexes and column order, covering indexes, the write cost, and how to read EXPLAIN to find the slow query.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 16

Press play to hear the narration, or step through at your own pace. Every picture here is the same one used in the YouTube video.

Notes

Without an index

Start with a table of ten million users and a query: find the user with this email. Without an index, the database has no idea where that row is. It reads every row, checks the email, and keeps the one match. That's a full table scan: ten million checks to return one row.

An index fixes this the way the index at the back of a book does. Instead of reading every page for one word, you look the word up in a sorted list that tells you the page. A database index is a sorted structure that maps a column's values to the rows that hold them.

One line creates it. Now the same query checks about three entries instead of ten million, and returns in four milliseconds. That's the single biggest performance tool you have, and the first thing to say in an interview when a query is slow.

Full table scan
SELECT * FROM users
WHERE email = 'priya@x.com';

rows checked: 10,000,000
rows returned: 1

time: ~4 s
Like a book
  • no index: read every page
  • to find one word
  • index at the back: look up
  • the word, jump to the page
  • a database index is the same idea
Create it
CREATE INDEX users_email_idx
  ON users (email);

rows checked: ~3
rows returned: 1

time: ~4 ms

B-tree

Inside, most indexes are B-trees. Think of a sorted tree that is very wide and very shallow: each node holds hundreds of keys, so ten million entries fit in a tree only three or four levels deep. A lookup walks down one path from the root to a leaf, and the leaf points at the row on disk.

That's why it's fast: the work grows with the logarithm of the table size, not the size itself. Doubling the table adds at most one level. And because the tree is sorted, it answers ranges too: everyone with an email starting with p, or every order between two dates.

Why it is fast
10,000,000 rows
B-tree with ~500 keys per node

levels = log₅₀₀(10,000,000) ≈ 3

full scan: 10,000,000 reads
index:     3 or 4 reads

What it can do

An index on a column speeds up equality, ranges, sorting by that column, and joins on it. It does not help when you wrap the column in a function, or search with a wildcard at the start, because the sorted order no longer matches what you're asking.

What an index can answer
✓ WHERE email = ?        equality
✓ WHERE created_at > ?   range
✓ ORDER BY created_at    sorting
✓ JOIN … ON user_id      join keys

✗ WHERE LOWER(email) = ? function
✗ WHERE email LIKE '%x'  wildcard

Composite indexes

You can index several columns together. A composite index on (user_id, created_at) is sorted by user first, then by date within each user. It serves queries that filter on user id, or user id and date. But it does not serve a query on date alone, because date isn't the first column. Put the equality column first, the range column second.

One more trick: a covering index. If every column the query needs is inside the index, the database answers from the index and never touches the table at all. Postgres lets you include extra columns for exactly this.

Composite index
CREATE INDEX orders_user_date_idx
  ON orders (user_id, created_at);
sorted by user_id, then created_at

✓ user_id = 7
✓ user_id = 7 AND created_at > ?
✗ created_at > ?  (not the first
                    column)
Covering index
SELECT email FROM users
WHERE email LIKE 'p%';

the index already holds email
→ answer from the index alone,
  never touch the table

INCLUDE (name) adds extra columns

The cost

Indexes are not free. Every insert, update and delete has to update every index on the table. Five indexes means each write is really six writes. They take disk space and memory too. So index what you query, and drop what you don't. A write-heavy table with ten indexes is a classic interview trap.

The cost
  • every INSERT, UPDATE, DELETE
  • must update every index
  • 5 indexes → each write does 6 writes
  • indexes take disk and memory
  • unused indexes are pure cost

EXPLAIN

How do you know which query needs an index? Ask the database. EXPLAIN ANALYZE prints the plan it used. Seq Scan means it read the whole table. Index Scan means it used the index. In practice you log slow queries, run EXPLAIN on them, and add the index the plan is missing.

Find the slow query
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = ?;

Seq Scan on users      ← full scan: bad
  rows=10000000

Index Scan using users_email_idx
  rows=1               ← good

Rules

Rules of thumb. Index the columns you filter, join and sort by. Primary keys are indexed automatically; foreign keys usually should be. Equality column first, range second. Keep the number small, and let EXPLAIN, not guesses, tell you what's slow.

Rules of thumb
  • index columns in WHERE, JOIN, ORDER BY
  • primary keys are indexed already
  • foreign keys: usually index them
  • equality column first, range second
  • few, well-chosen indexes
  • measure with EXPLAIN, not guesses

In the interview

In the interview, say it like this. Looking up a user by email is the hot path, so I'd put a B-tree index on email. Orders are fetched per user by date, so a composite index on (user_id, created_at). On the write-heavy events table I'd keep indexes minimal. Hot query, index, trade-off. That's the whole answer.

Say it like this
  • “Looking up a user by email is the
  • hot path, so I’d put a B-tree index
  • on email. Orders are fetched per
  • user by date, so a composite index
  • on (user_id, created_at). I’d keep
  • indexes minimal on the write-heavy
  • events table and check EXPLAIN.”

Interview questions

What is a database index?

A separate sorted structure, usually a B-tree, that maps a column’s values to the rows holding them, so the database can jump to matching rows instead of scanning the whole table.

Why is a B-tree lookup fast?

The tree is wide and shallow: each node holds hundreds of keys, so even ten million rows fit in three or four levels. A lookup follows one path from root to leaf, so the work grows with the logarithm of the table size.

Which queries can an index speed up?

Equality (WHERE col = ?), ranges (WHERE col > ?), sorting (ORDER BY col) and joins on the column. It cannot help when the column is wrapped in a function or searched with a leading wildcard (LIKE ‘%x’), because the sorted order no longer matches the predicate.

Composite index on (user_id, created_at): which queries use it?

Queries filtering on user_id, or on user_id and created_at together, and ordering by created_at within a user. A query filtering only on created_at cannot use it, because created_at is not the leading column. Put the equality column first and the range column second.

What is a covering index?

An index that contains every column the query needs, so the database answers from the index alone without reading the table. In PostgreSQL, INCLUDE adds non-key columns for this purpose.

What does an index cost?

Every insert, update and delete must also update each index, so writes get slower in proportion to the number of indexes. Indexes also consume disk and memory. Unused indexes are pure cost and should be dropped.

How do you find which query needs an index?

Log slow queries, then run EXPLAIN ANALYZE on them. A Seq Scan over a large table means no index was used; an Index Scan means one was. Add the index the plan is missing and measure again.