41. B-tree indexes
What an index actually is
An index is a separate data structure, stored alongside a table,
that lets the engine find matching rows without reading every row in the
table — the difference between a Seq Scan (previous lesson) and an
Index Scan. Postgres's default, general-purpose index type is the
B-tree (balanced tree) — a sorted, hierarchical structure that
supports fast equality lookups, range queries, and sorted retrieval, all
with O(log n) search cost instead of O(n).
(The full internal structure — nodes, pages, how the tree balances itself as it grows — is genuinely visual and worth looking up if you're curious, but beyond this course's scope. This lesson focuses on using B-tree indexes correctly; conceptually, think of it like a phone book's alphabetical sorting: you don't scan every name to find "Smith," you jump to the right section directly.)
Creating one, and seeing the plan change
-- Without an index, filtering on email requires a full scan.
EXPLAIN ANALYZE SELECT * FROM customer WHERE email = 'MARY.SMITH@sakilacustomer.org';
CREATE INDEX idx_customer_email ON customer (email);
-- Same query, now with an index available.
EXPLAIN ANALYZE SELECT * FROM customer WHERE email = 'MARY.SMITH@sakilacustomer.org';
The second plan should show Index Scan using idx_customer_email instead
of Seq Scan on customer — and, on a table this small (599 rows), the
actual time difference may be tiny (sequential scans on small tables are
already fast); the benefit compounds dramatically as table size grows,
which is exactly why this module keeps returning to EXPLAIN ANALYZE —
"does this index actually get used, and does it actually help" is an
empirical question, not something to assume from syntax alone.
What operations a B-tree index actually accelerates
=, <, <=, >, >=, BETWEEN, and sorting (ORDER BY) on the
indexed column. Not LIKE '%pattern%' with a leading wildcard —
here's precisely why: a B-tree is sorted by
the whole value, so it can — in principle — jump straight to values
starting with 'ACE' (LIKE 'ACE%', a prefix match), but it has no
way to jump to "contains ACE anywhere," since that's not a property the
sort order exposes:
-- B-tree CANNOT help, no matter what: no leading anchor, sort order is
-- useless here — true regardless of index type or collation.
EXPLAIN SELECT * FROM film WHERE title LIKE '%GOLDFINGER%';
A real, non-obvious catch on the prefix case: a plain B-tree index
on a text column, under any locale-aware collation (Postgres's default
— check yours with SHOW lc_collate), does not actually accelerate
LIKE 'ACE%', even though the reasoning above says it should be able
to. This is because locale-aware string sorting doesn't always agree
with simple byte-order prefix matching (accents, case rules, and
locale-specific collation rules can reorder strings in ways that break
the "jump to the prefix" shortcut). Proof, on pagila's default locale:
CREATE INDEX idx_film_title ON film (title);
EXPLAIN SELECT * FROM film WHERE title LIKE 'ACE%';
-- Still a Seq Scan — the plain index doesn't help this query at all.
The fix: index the column with the text_pattern_ops operator class
specifically, which sorts by raw byte value instead of locale rules —
exactly what prefix matching needs:
DROP INDEX idx_film_title;
CREATE INDEX idx_film_title_pattern ON film (title text_pattern_ops);
EXPLAIN SELECT * FROM film WHERE title LIKE 'ACE%';
-- Now: Index Scan, condition rewritten internally as a range
-- (title >= 'ACE' AND title < 'ACF').
The general lesson under the specific fix: "a B-tree index exists on
this column" is not the same question as "this specific query can
actually use it" — the operator class, the collation, and the exact
predicate shape all matter, and EXPLAIN is the only reliable way to
confirm an index is actually helping a given query rather than assuming
it from the CREATE INDEX statement alone.
Index-only scans: when the index alone is enough
If every column a query needs is present in the index itself, the
engine never needs to touch the underlying table (the "heap") at all —
this is an Index Only Scan, visibly faster than an ordinary Index
Scan because it skips a whole extra round of row lookups. This needs a
column selective enough that the planner actually prefers the index over
a sequential scan — rental.rental_date on the 16,044-row rental
table is a good candidate (pagila's film.rental_rate only has 3
distinct values, too low-cardinality for the planner to ever prefer an
index over a seq scan on a 1000-row table — worth knowing that "add an
index" doesn't automatically mean "the planner will use it," exactly the
point the previous section just made):
CREATE INDEX idx_rental_date ON rental (rental_date);
-- Needs ONLY rental_date -- the index alone can answer this fully.
EXPLAIN ANALYZE SELECT rental_date FROM rental WHERE rental_date > '2022-08-20';
-- Plan shows "Index Only Scan" and "Heap Fetches: 0" -- proof the heap
-- was never touched.
-- Needs rental_id too, which ISN'T in this index -- forces a heap lookup
-- per matching row (a regular Index Scan, not Index Only).
EXPLAIN ANALYZE SELECT rental_id, rental_date FROM rental WHERE rental_date > '2022-08-20';
(An Index Only Scan also depends on Postgres's visibility map
being up to date — a detail tied to MVCC and VACUUM, Postgres
internals beyond this course's scope — mentioned here so the term
doesn't surprise you later; it doesn't change
anything about how you'd write the query.)
Covering indexes: deliberately widening an index to enable Index Only Scan
A covering index deliberately includes extra columns specifically so
more queries can become index-only scans — via the INCLUDE clause,
which adds columns to the index without making them part of the sort
key (so they don't help with filtering/sorting on those columns, only
with avoiding the heap lookup):
DROP INDEX idx_rental_date;
CREATE INDEX idx_rental_date_covering ON rental (rental_date) INCLUDE (rental_id);
-- Now this CAN be an Index Only Scan -- both rental_date (the search
-- key) and rental_id (an INCLUDEd column) live in the index itself.
EXPLAIN ANALYZE SELECT rental_id, rental_date FROM rental WHERE rental_date > '2022-08-20';
This is a deliberate space-for-speed tradeoff — the index is now larger
(every row's title is duplicated into it) in exchange for more queries
avoiding the heap entirely. Not a default to reach for on every index;
appropriate specifically when a query pattern is frequent and
performance-critical enough to justify the extra storage and slightly
slower writes (every index makes INSERT/UPDATE marginally more
expensive, since the index has to be maintained too — covered fully in
the performance-killers lesson later in this module).
Check yourself
- Why can a B-tree index accelerate
LIKE 'ACE%'but notLIKE '%GOLDFINGER%'? - What makes an
Index Only Scanfaster than an ordinaryIndex Scan? - What does the
INCLUDEclause onCREATE INDEXactually add to an index, and why doesn't it help with filtering on those included columns?