Lesson 3 of 9 · 22m
Indexes That Actually Get Used
B-tree, GIN, GiST, partial and covering indexes — how the planner decides, and why most 'add an index' advice is written by people who never ran EXPLAIN.
Indexes aren't free — every one makes reads faster and writes slower, and the wrong index just costs you write throughput for nothing. The skill isn't knowing index types; it's knowing which predicate the planner can use an index for.
B-tree is for ordered lookups
The default index type answers =, <, >, BETWEEN, LIKE 'prefix%', ORDER BY. It does not help LIKE '%suffix' — a B-tree can't jump into the middle of keys. For that, pg_trgm's GIN index turns LIKE '%substring%' into trigram lookups.
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
-- usable for: WHERE user_id = 5 ORDER BY created_at DESC
-- NOT usable for: WHERE created_at > now() (user_id missing — leftmost prefix rule)Composite indexes obey the leftmost prefix rule: the index on (a, b, c) helps WHERE a=, a AND b, a AND b AND c — not WHERE b= alone.
The specialized arsenal
- Partial index — index only rows that matter:
CREATE INDEX ON orders (user_id) WHERE status = 'open'— small, fast, answers the hot query. - Covering index —
INCLUDEcarries extra columns so the planner never touches the heap:CREATE INDEX ON users (email) INCLUDE (name)enables index-only scans. - GIN — for containment:
jsonb, arrays,tsvectorfull-text search, trigrams. - GiST — geometric/range types; exclusion constraints (
tsrange &&overlap checks) are built on it. - BRIN — for huge tables where the physical order correlates with the column (append-only time series): tiny index, scans ranges of blocks.
When the index exists but isn't used
- Wrong estimate — run
ANALYZEon the table; stale stats make the planner pessimistic. - The index returns more than ~5-10% of rows — a sequential scan is honestly faster.
- Function on the column —
WHERE lower(email) = 'x'can't useemailunless you indexedlower(email)as an expression.
::exercise(index-selectivity)
Field notes
CREATE INDEX CONCURRENTLYbuilds without locking writes — slower, but the only option on a busy table.pg_stat_user_indexesshowsidx_scancounts — an index never used is dead weight to drop.