Career OS

Learn · SQL · Core

SQL Indexes

An index is why a database can find one row among 50 million in milliseconds. It’s the same idea as the index at the back of a book — and the same idea as binary search.

Before we start

📋 What you’ll learn
  • What a database index is and how it speeds lookups
  • Why an index turns a full scan into a log-time seek
  • The trade-off: faster reads, slower writes
  • Which columns are worth indexing
✅ After this you’ll be able to
  • Explain why a query is slow and how an index fixes it
  • Decide which columns to index in a schema
  • Connect indexes to binary search / trees

Why you’re learning it: “why is this query slow?” is a real-job and interview staple, and the answer is almost always indexes. It also ties your Binary Search and Trees lessons to real systems. ⏱️ ~25 min.

The idea

Want to find every mention of “mitochondria” in a 900-page textbook. Without an index: read all 900 pages — a full scan. With the index at the back: jump straight to “page 412, 588.” A database index is exactly that — a sorted lookup structure (usually a B-tree, a tree that stays balanced) that points to where rows live. It turns a full table scan (O(n)) into a seek (O(log n)).

The trade-off — nothing is free

✓ Speeds up reads

Lookups, filters (WHERE), joins and sorts on the indexed column become dramatically faster.

✕ Slows down writes

Every INSERT/UPDATE must also update the index. Indexes cost space and write time — so you don’t index everything.

What to index

EXPLAIN before a query shows whether it’s using an index or scanning — the tool you reach for when something’s slow.

Where you’ll use it — real life

🐢 The 30-second page

A page that crawls in production is often a query with no index doing a full scan. Adding one index can take it from 30s to 30ms.

🔎 Login & lookups

Finding a user by email across millions of rows needs an index on email — instant instead of a scan.

🔁 Reconciliation

Matching on a reference id is fast only if that column is indexed — your Recon project depends on it.

📈 Reports & sorting

“Latest 100 orders” is cheap with an index on the date column, brutal without.

What a B-tree actually looks like

Your table is a heap — rows sit in insertion order, unsorted. An index doesn’t sort the table; it builds a separate sorted structure beside it. That structure is a B-tree: a short, fat tree with hundreds of children per node (not two, like the binary trees you met earlier). Each leaf holds an indexed value plus a pointer back to the real row in the heap.

Looking up one value walks root → branch → leaf → follow the pointer. That’s three or four page reads instead of thousands. And it’s the same logarithm as binary search: doubling the table adds roughly one hop. 100k rows is a 3-level tree; 100 million rows is maybe 5. That’s why indexes scale and scans don’t.

Seeing it with your own eyes — EXPLAIN

EXPLAIN shows the plan PostgreSQL intends to use. EXPLAIN ANALYZE actually runs the query and reports what really happened — estimate and reality side by side. (Because it runs for real, wrap it in BEGIN; ... ROLLBACK; when the query is an INSERT/UPDATE/DELETE.)

Before an index — a query filtering a big table on an unindexed column:

Seq Scan on expense  (cost=0.00..2786.00 rows=120 width=58)
                     (actual time=0.014..11.073 rows=119 loops=1)
  Filter: (created_at >= (now() - '02:00:00'::interval))
  Rows Removed by Filter: 99881
Execution Time: 11.205 ms

Read every plan in this order: (1) the scan node — Seq Scan means it read the whole table, your alarm bell. (2) Rows Removed by Filter — here it touched 100,000 rows to keep 119; that wasted work is the case for an index. (3) estimated vs actual rows — far apart means stale statistics; run ANALYZE. (4) Execution Time — the real bottom line in milliseconds; write it down as your “before”.

One trap: cost=0.00..2786.00 is not milliseconds. It’s the planner’s abstract page-fetch currency for comparing plans against each other. Real time only shows in actual time and Execution Time, and only with ANALYZE.

Now add the index and re-run:

CREATE INDEX idx_expense_created_at ON expense (created_at);
Index Scan using idx_expense_created_at on expense
    (cost=0.42..10.51 rows=120 width=58)
    (actual time=0.025..0.098 rows=119 loops=1)
  Index Cond: (created_at >= (now() - '02:00:00'::interval))
Execution Time: 0.131 ms

11.2 ms → 0.13 ms — about 85x, and the gap widens as the table grows. Notice the line changed from Filter to Index Cond: the condition is now answered inside the tree, not checked against every row. Zero rows removed, zero waste.

When the planner ignores your index — and is right to

You create an index, run EXPLAIN, and still see Seq Scan. PostgreSQL isn’t broken — it did arithmetic you haven’t. An index scan pays a price per matched row: hop the tree, then a random jump into the heap to fetch the actual row. Cheap for 119 rows; for 25,000 rows that’s 25,000 random jumps — slower than reading the whole table front to back in one sweep.

The deciding factor is selectivity — what fraction of the table the query wants:

The rule: an index is a bet that the query wants a small slice. When the planner ignores it, believe the arithmetic before your feelings — then verify with EXPLAIN ANALYZE.

What indexes cost — why you don’t index everything

An index is a second sorted structure that must stay correct forever. Every INSERT now writes the heap row plus an entry into every index on the table (finding the leaf, splitting pages when they fill). Every UPDATE of an indexed column is a delete-plus-insert inside the tree. This is write amplification: one logical write becomes 1 + N physical writes for N indexes.

3 indexes on a table

Each INSERT writes the heap row + maintains 4 trees (the PK plus your 3).

8 “just in case” indexes

Each INSERT maintains 9 trees; write throughput quietly halves. Indexes also add 50–100% to table size on disk.

The discipline: index the columns your queries actually filter, join, and sort on. Prove each with EXPLAIN ANALYZE. Drop the ones nothing uses.

Composite indexes and the leftmost-prefix rule

“This group’s expenses, newest first” filters on one column and sorts on another. One index can serve both:

CREATE INDEX idx_expense_group_created ON expense (group_id, created_at);

A composite index is sorted by the first column, then by the second within each value of the first — like a phone book sorted by surname, then first name within each surname. So:

Column-order corollary: equality columns first, range columns last. (group_id, created_at) pins the group then walks one contiguous time range; (created_at, group_id) would walk a time range and re-check the group on every row.

The index killers

Two patterns silently disable indexes you paid for:

1. The leading wildcard. WHERE description ILIKE '%dinner%'. A B-tree is sorted by how strings start; a pattern beginning with % says “I don’t know how it starts,” so the sort is worthless and PostgreSQL scans. 'dinner%' (anchored) can use an index; '%dinner%' never can.

2. A function wrapped around the column. WHERE lower(description) = 'auto expense 4242' ignores an index on description — the tree stores the raw value, but the query asks about lower(description), a value the tree never saw. The fix is an expression index on the computed value:

CREATE INDEX idx_expense_description_lower ON expense (lower(description));

The same trap hides in WHERE created_at::date = '2026-06-12' (a cast is a function) and WHERE amount_paise / 100 = 123. Rule: keep the indexed column naked on the left of the operator; move the maths to the other side, or index the expression.

You already own indexes — PK and UNIQUE

Every uniqueness constraint is implemented as a B-tree index — that’s how the database enforces it fast. A PRIMARY KEY? Indexed. A UNIQUE column like email? Indexed. Run \d expense in psql and read the Indexes: section — lookups by id were never your problem.

The FK trap — foreign keys are NOT auto-indexed

PostgreSQL auto-indexes primary keys and UNIQUE constraints — and nothing else. Foreign-key columns get no index until you create one. Every @ManyToOne in a JPA entity becomes an FK column, and every derived query like findByGroupId(...) filters on it. Dev with 50 rows: instant. Production with a year of data: a Seq Scan per request.

The famous cross-database twist: MySQL (InnoDB) auto-indexes FK columns; PostgreSQL does not. Engineers moving from MySQL assume the index exists and ship the regression. Bonus pain: deleting a parent row makes PostgreSQL check no child references it — without an FK index, that’s a full scan per delete. The rule: every FK column your app queries or joins by gets an explicit CREATE INDEX, written into a migration.

How this shows up at work — and in interviews

🚨 The month-end incident

A report endpoint fine all month takes 40s on the 30th. Whoever runs EXPLAIN ANALYZE, sees Rows Removed by Filter: 4,812,331, and ships a one-line CREATE INDEX is the hero of the postmortem.

🧊 The delete that froze the app

Deleting one customer takes minutes because every child table’s FK column is unindexed — a full scan per parent delete. Seniors check FK indexes in every schema review.

🧑‍💻 The over-indexer

A teammate adds indexes on all twelve columns “to be safe.” Explaining write amplification and asking which query each index serves is the seniority signal — arguing against an index.

🎤 The interview staple

“Index on (a, b) — does a query filtering only on b use it? Why?” Answer with the sorted phone-book structure, not a memorized rule. Follow-up: “why might the planner skip an index that exists?” — say selectivity.

Now YOU do the reps

🗣️ The 2-minute explain test

Out loud: “What does an index do, why does it make reads fast but writes slower, and which columns would I index?” Then log it in your Journal.


Next: SQL Transactions →

Saves your progress on this device.
00:00