AcademySQL
Indexes: what a B-tree cannot do
Why column order in a composite index decides which queries it serves, what makes a condition unindexable, and the reason an index sometimes makes things slower.
3 min readUpdated 3 exercises
An index is a sorted copy of some columns
A B-tree index holds the indexed columns, sorted, plus a pointer back to the row. That one sentence explains everything an index is good at and everything it is not.
Sorted data supports: equality, ranges, prefixes, and reading in order without a sort. It does not support anything that would require looking inside a value it has not stored in sorted form.
CREATE INDEX idx_orders_customer ON orders (customer_id);Now finding one customer's orders is a walk down a tree instead of a scan of the table — the difference between a few reads and all of them.
Composite indexes: the order is the whole design
CREATE INDEX idx ON orders (customer_id, status, created_at);This index is sorted by customer_id first, then status within each customer, then created_at within each pair. It therefore serves a query that constrains a prefix of that list:
| Query filters on | Index used? |
|---|---|
customer_id | yes |
customer_id, status | yes |
customer_id, status, created_at | fully |
customer_id, created_at | partly — for customer_id, then a filter |
status alone | no |
status, created_at | no |
Think of a phone book sorted by last name then first name: finding every "Martin" is easy, finding every "Claire" is not. The rule that follows: put the columns you filter by equality first, and the column you use for a range or an ORDER BY last — a range stops the index being useful for anything after it.
What stops an index being used
An index on a column cannot help when the query asks about an expression of that column:
WHERE LOWER(email) = 'a@b.c' -- index on email is useless
WHERE created_at::date = '2024-01-01'
WHERE total + 0 > 100
WHERE email LIKE '%@gmail.com' -- leading wildcard: no prefix to seekEach has a fix: index the expression itself where the engine allows it, or rewrite the condition to leave the column bare —
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'
WHERE email LIKE 'claire%' -- a prefix is a range, and ranges are fineA type mismatch does it too: comparing a text column to a number makes the engine convert one side, and the converted side is an expression.
The second read, and how to avoid it
Finding the entry is half the work; the engine then usually reads the row itself for the columns you selected. Include them in the index and it does not have to:
CREATE INDEX idx ON orders (customer_id) INCLUDE (total, status);That is a covering index — the query is answered from the index alone. Worth it on a hot read path, at the cost of a bigger index to keep up to date.
Indexes are not free
Every index is a second structure to update on every write, extra storage, and one more thing the planner has to consider. An index nothing queries is pure cost. And below a certain selectivity, the engine will refuse to use one on purpose:
SELECT * FROM orders WHERE status = 'paid'; -- if 90% are paidReading most of a table through an index means random reads of nearly every page, which is slower than reading the table sequentially. The planner knows that from its statistics and chooses the scan. That is not a bug; it is the index being honestly assessed as the worse option.
Read the plan, do not guess
EXPLAIN — EXPLAIN ANALYZE where it exists — is the only way to know what actually happened. Guessing whether an index is used, on a query you did not measure, is how teams end up with twelve indexes and one slow endpoint.
Now practice it
3 exercisesReading this page is the cheap half. These are the exercises that make you use it.
- Indexes & Performance · Index BasicsIndex basics: what one stores, and when the engine can seek it rather than scan the table.QuizTake · EasyQuizTake · Easy
- Indexes & Performance · Composite & Partial IndexesComposite and partial indexes, where the column order decides which queries are served at all.QuizTake · MediumQuizTake · Medium
- Indexes & Performance · GIN, GiST & BRINGIN, GiST and BRIN — the structures for the questions a B-tree cannot answer.QuizTake · HardQuizTake · Hard
Read next
JOINs, and the rows you did not expect
What a join really computes, the difference INNER and LEFT actually make, and why filtering a LEFT JOIN in the WHERE clause silently turns it back into an INNER one.
3 min
Transactions and isolation levels
What ACID actually promises, which anomalies each isolation level still allows, and why the read-then-write you wrote is a race unless you said otherwise.
3 min