Indexing & Query Optimization
Turn slow scans into fast lookups with indexes. Learn how btree indexes work, how to design composite and covering indexes, and how to read EXPLAIN plans to prove a fix worked.
What is this?
An index is an auxiliary structure the database maintains to find rows without scanning the whole table. The default btree index keeps keys sorted with pointers to rows, turning an O(n) scan into an O(log n) lookup. Because every index costs writes and storage, the planner weighs index scans, sequential scans and bitmap scans using cost estimates built from table statistics.
Why do you need it?
- Indexes are the highest-leverage read-latency fix: one CREATE INDEX can cut a query from seconds to milliseconds.
- They serve both point lookups and range queries because btree keys are sorted.
- UNIQUE and PRIMARY KEY constraints are implemented as indexes, so they also enforce integrity.
- A covering index can answer a query entirely from the index pages, skipping the table entirely.
- Understanding indexes lets you read EXPLAIN plans and influence what the cost-based planner decides.
Mental model
The index at the back of a book
A database index
You jump straight to the right page instead of flipping through the whole book. The book works without the index — searching is just slower. Every reprint (write) must keep the index accurate too.
How it works
- CREATE INDEX builds a btree of key → row pointer (TID) pairs, sorted by key value.
- A query filtering on the indexed column descends the tree with O(log n) comparisons.
- The planner estimates selectivity from statistics (pg_class.reltuples, pg_stats) to choose index vs seq scan.
- For range or multi-row matches, the engine reads a contiguous slice of leaf pages and fetches the matching heap rows.
- Every INSERT/UPDATE/DELETE must maintain the index, trading write cost for read speed.
- ANALYZE refreshes planner statistics so cost estimates stay accurate as data changes.
How a btree index turns a full scan into a point lookup.
Key commands
CREATE INDEX idx_users_email ON users (email);Create a btree index for equality lookups on email.
EXPLAIN SELECT ...Show the query plan without executing it.
EXPLAIN (ANALYZE, BUFFERS) SELECT ...Execute the query and report actual timings, buffers and rows.
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);Create a composite index matching a filter-plus-sort pattern.
CREATE INDEX idx_orders_active ON orders (user_id) WHERE status = 'active';Create a partial index covering only hot rows.
\diList all indexes in the current database.
Examples
Before and after an index, with EXPLAIN
-- Slow: the planner must read every row of the tableEXPLAIN SELECT * FROM users WHERE email = 'ada@example.com';-- Seq Scan on users (cost=0.00..1234.56 rows=1 width=448) CREATE INDEX idx_users_email ON users (email); -- Fast: an index scan finds the key in O(log n)EXPLAIN SELECT * FROM users WHERE email = 'ada@example.com';-- Index Scan using idx_users_email on users (cost=0.28..8.29 rows=1)-- Index Cond: (email = 'ada@example.com'::text)Before the index, the planner commits to a Seq Scan across the whole table. After CREATE INDEX, it switches to an Index Scan that descends the btree to the matching key and fetches only that row. The cost figures in parentheses are estimates; ANALYZE keeps them honest.
In production
- Foreign-key columns are indexed so JOIN lookups are cheap instead of nested-loop scans.
- Composite indexes on (user_id, created_at) power 'recent activity' feeds in O(log n).
- Partial indexes on recent or active rows keep the index small for hot subsets.
- Covering indexes serve reporting queries straight from the index, removing heap access.
Best practices
- Index the columns in WHERE, JOIN and ORDER BY clauses — but only after measuring that the query is slow.
- Order composite index columns equality-first, then range or sort columns.
- Prove the win with EXPLAIN (ANALYZE, BUFFERS) before and after adding an index.
- Keep write-heavy tables lean — every extra index slows INSERT and grows bloat.
- Prune unused indexes by querying pg_stat_user_indexes for idx_scan counts.
Common mistakes
- Indexing low-cardinality columns where a sequential scan is already fast, wasting write work.
- Wrapping an indexed column in a function (WHERE lower(email) = ...) without a matching functional index.
- Creating several single-column indexes when one composite index would serve several queries.
- Adding indexes to patch a query that a schema fix or a missing WHERE clause would solve better.
Common issues
Seq Scan on users (cost=0.00..1234.56 rows=1) despite a selective filterNo index exists on the filtered column, or statistics are stale.
CREATE INDEX on the column, run ANALYZE, and re-check with EXPLAIN.
Planner picks a bad plan after a bulk loadpg_statistic is stale and the estimates no longer match reality.
Run ANALYZE (autovacuum does this eventually) and check fillfactor for bloat.
Plan shows Bitmap Heap Scan instead of Index ScanThe planner estimates many matches and prefers bitmap mode.
This is usually correct behavior; investigate only if the plan is measurably slow.
Debugging tips
- Run EXPLAIN (ANALYZE, BUFFERS) and compare estimated vs actual rows to spot stale statistics.
- Run ANALYZE after bulk loads so the planner sees the current distribution.
- Check pg_stat_user_indexes.idx_scan for indexes that are never used.
- A Seq Scan on a huge table with a restrictive filter is the classic missing-index smell.
Interview checkpoint
Hands-on lab
- Generate a large dataset
- Measure a slow query with EXPLAIN (ANALYZE, BUFFERS)
- Design and create the right composite index
- Prove the improvement with before/after plans
Making a Slow Query Fast
30 min · intermediate
Quick quiz
Which query shape benefits most from a btree index?
Every index speeds up all queries at zero ongoing cost.
After adding an index, INSERT throughput on a hot table drops noticeably. What is the most likely reason?
Cheat sheet
CREATE INDEXBuild a btree indexCREATE UNIQUE INDEXEnforce uniquenessCREATE INDEX ... WHEREPartial index on a subsetDROP INDEXRemove an indexANALYZERefresh planner statisticsSeq ScanReads the whole tableIndex ScanPoint lookup via btreeBitmap Heap ScanMany matches via bitmapNested LoopPer-outer-row inner lookupHash JoinHash one side, probe the otherMerge JoinSorted inputs merged