This Note: The SQL side — CREATE INDEX variants, CONCURRENTLY, EXPLAIN ANALYZE, and the query patterns that silently defeat indexes
Verified: All plans below were captured from PostgreSQL 17 on the 200k-row table created in the next section
Lab Table
-- 200k synthetic events (deterministic, ~1 second to build)DROP TABLE IF EXISTS events;CREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id int NOT NULL, kind text NOT NULL, -- view 79%, click 15%, purchase 5%, refund 1% email text NOT NULL, amount numeric(10,2), props jsonb NOT NULL, created_at timestamptz NOT NULL);INSERT INTO events (user_id, kind, email, amount, props, created_at)SELECT (g * 7919) % 10000 + 1, CASE WHEN g % 97 = 0 THEN 'refund' WHEN g % 20 = 0 THEN 'purchase' WHEN g % 5 = 0 THEN 'click' ELSE 'view' END, 'User' || g || '@Example.com', CASE WHEN g % 20 = 0 THEN (g % 500) + 0.99 END, jsonb_build_object('device', (ARRAY['ios','android','web'])[1 + g % 3], 'campaign', 'c' || (g % 50)), timestamptz '2025-01-01 00:00+00' + g * interval '1 minute'FROM generate_series(1, 200000) AS g;VACUUM ANALYZE events; -- fresh statistics + visibility map (needed for index-only scans)
Reading EXPLAIN
Variants
Command
Runs Query
Shows
EXPLAIN
No
Estimated plan + costs
EXPLAIN ANALYZE
Yes (wrap writes in BEGIN … ROLLBACK)
Actual rows, time, loops
EXPLAIN (ANALYZE, BUFFERS)
Yes
+ pages hit in cache / read from disk (default with ANALYZE in PG 18)
EXPLAIN (COSTS OFF)
No
Plan shape only — handy for notes/tests
EXPLAIN (FORMAT JSON)
No
Machine-readable, paste into visualisers (explain.dalibo.com)
How to Read a Node
Index Scan using events_user_created_idx on events (cost=0.42..21.30 rows=20 width=16) (actual time=0.010..0.015 rows=5 loops=1)│ │ │ │ │ │ │ │ ││ │ │ │ │ │ │ │ └ times this node ran (multiply!)│ │ │ │ │ │ │ └ rows actually returned (per loop)│ │ │ │ │ │ └ ms to first row .. ms to last row (per loop)│ │ │ │ │ └ avg row width in bytes│ │ │ │ └ ESTIMATED rows│ │ │ └ total cost (arbitrary units)│ │ └ startup cost└ node type: how rows are produced
Read Inside-Out: The most indented node runs first and feeds its parent
Estimate vs Actual: rows=20 estimated vs rows=20000 actual → stale stats or correlated columns → ANALYZE, extended statistics
Rows Removed by Filter: Large number = the index didn’t cover that condition
loops: Inner side of a Nested Loop runs once per outer row — actual time × loops is the real cost
Buffers shared read: Pages fetched from disk/OS cache; hit = Postgres buffer cache
Common Node Types
Node
Meaning
Good When
Seq Scan
Read whole table
Small table or most rows needed
Index Scan
Walk index, fetch each heap row
Few rows, or ordered output needed
Index Only Scan
Answer from index alone (Heap Fetches: 0 ideal)
Covering index + well-vacuumed table
Bitmap Index/Heap Scan
Collect TIDs, then read heap pages in order
Medium selectivity, OR / AND of indexes
Nested Loop
For each outer row, probe inner
Small outer, indexed inner
Hash Join
Build hash of one side, probe with other
Big unsorted equi-joins
Merge Join
Merge two sorted inputs
Both already sorted
Sort / Incremental Sort
Sort rows (Sort Method: external merge = spilled to disk)
Raise work_mem or add an index on the order
HashAggregate / GroupAggregate
GROUP BY via hash or sorted input
Gather / Parallel Seq Scan
Parallel workers
Large scans
EXPLAIN (ANALYZE, BUFFERS)SELECT o.id, sum(oi.quantity * oi.unit_price)FROM orders o JOIN order_items oi ON oi.order_id = o.idWHERE o.user_id = 1GROUP BY o.id;
CREATE INDEX Variants
B-tree (Default)
Supports: =, <, <=, >, >=, BETWEEN, IN, IS NULL, ORDER BY, prefix LIKE 'abc%' (needs text_pattern_ops or C collation)
Before: Seq Scan … Rows Removed by Filter: 199980
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)SELECT * FROM events WHERE user_id = 42;CREATE INDEX events_user_id_idx ON events (user_id);EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)SELECT * FROM events WHERE user_id = 42;
-- beforeSeq Scan on events (actual rows=20 loops=1) Filter: (user_id = 42) Rows Removed by Filter: 199980-- afterBitmap Heap Scan on events (actual rows=20 loops=1) Recheck Cond: (user_id = 42) Heap Blocks: exact=20 -> Bitmap Index Scan on events_user_id_idx (actual rows=20 loops=1) Index Cond: (user_id = 42)
Composite Index and Column Order
Leftmost Prefix Rule: Index on (a, b) serves WHERE a = … and WHERE a = … AND b …, but not WHERE b = … alone (efficiently)
Order Matters: Equality columns first, then the range / ORDER BY column
Removes Sorts: WHERE user_id = ? ORDER BY created_at DESC LIMIT n becomes a short index walk
-- Single-column index: needs a Sort on topEXPLAIN (COSTS OFF)SELECT id, created_at FROM events WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5;CREATE INDEX events_user_created_idx ON events (user_id, created_at DESC);-- Composite: rows come out of the index already ordered — no Sort nodeEXPLAIN (COSTS OFF)SELECT id, created_at FROM events WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5;
-- before -- afterLimit Limit -> Sort -> Index Scan using events_user_created_idx on events Sort Key: created_at DESC Index Cond: (user_id = 42) -> Bitmap Heap Scan on events -> Bitmap Index Scan on events_user_id_idx
Covering Index (INCLUDE)
What: Store extra non-key columns in the index leaf so the query never touches the table → Index Only Scan
INCLUDE vs Adding to Key: Included columns don’t affect ordering/uniqueness and keep the upper tree small
Needs Vacuum: Index-only scans check the visibility map; un-vacuumed pages show up as Heap Fetches
CREATE INDEX events_user_created_cov ON events (user_id, created_at DESC) INCLUDE (kind, amount);VACUUM events;EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)SELECT created_at, kind, amount FROM events WHERE user_id = 42 ORDER BY created_at DESC LIMIT 5;
Limit (actual rows=5 loops=1) -> Index Only Scan using events_user_created_cov on events (actual rows=5 loops=1) Index Cond: (user_id = 42) Heap Fetches: 0
Partial Index
What: Index only rows matching a WHERE — smaller, faster, cheaper to maintain
Use: Hot subsets (status = 'queued', deleted_at IS NULL, rare kinds), partial uniqueness
Rule: The query’s WHERE must imply the index predicate
CREATE INDEX events_refunds_idx ON events (created_at) WHERE kind = 'refund';EXPLAIN (COSTS OFF)SELECT * FROM events WHERE kind = 'refund' AND created_at >= '2025-03-01';SELECT pg_size_pretty(pg_relation_size('events_refunds_idx')) AS partial_idx, -- 64 kB pg_size_pretty(pg_relation_size('events_user_id_idx')) AS full_idx; -- ~1.5 MB
Index Scan using events_refunds_idx on events Index Cond: (created_at >= '2025-03-01 00:00:00+00'::timestamp with time zone)
Expression Index
What: Index the result of an expression; the query must use the exact same expression
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE lower(email) = 'user42@example.com'; -- Parallel Seq ScanCREATE INDEX events_lower_email_idx ON events (lower(email));ANALYZE events; -- expression indexes get their own statisticsEXPLAIN (COSTS OFF) SELECT * FROM events WHERE lower(email) = 'user42@example.com'; -- Index Scan
Index Scan using events_lower_email_idx on events Index Cond: (lower(email) = 'user42@example.com'::text)
GIN for JSONB, Arrays and Full-Text
Operator Class
Supports
Size
jsonb_ops (default)
@>, ?, `?
, ?&, @?, @@`
jsonb_path_ops
@>, @?, @@ only
Smaller, faster for containment
array_ops
@>, <@, &&, = on arrays
gin_trgm_ops (pg_trgm)
LIKE '%x%', ILIKE, ~, similarity
CREATE INDEX events_props_gin ON events USING gin (props jsonb_path_ops);EXPLAIN (COSTS OFF)SELECT count(*) FROM events WHERE props @> '{"campaign": "c7"}';
Aggregate -> Bitmap Heap Scan on events Recheck Cond: (props @> '{"campaign": "c7"}'::jsonb) -> Bitmap Index Scan on events_props_gin Index Cond: (props @> '{"campaign": "c7"}'::jsonb)
Partitioned spaces — IPs, quad-trees, text prefixes
BRIN
Huge append-only tables where values correlate with physical order (timestamps) — tiny index
UNIQUE and CONCURRENTLY
Plain CREATE INDEX: Blocks writes (SHARE lock) for the whole build — OK in dev, dangerous on hot tables
CREATE INDEX CONCURRENTLY: Doesn’t block writes; takes longer (two table scans), can’t run inside a transaction block
If It Fails: Leaves an INVALID index — drop it and retry (DROP INDEX CONCURRENTLY)
REINDEX CONCURRENTLY: Rebuild a bloated index without blocking (PG 12+)
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_kind_idx ON events (kind);-- Find invalid leftovers from failed concurrent buildsSELECT indexrelid::regclass AS index_name FROM pg_index WHERE NOT indisvalid;
Index Anti-Patterns
1. Function or Cast on the Indexed Column
CREATE INDEX events_created_idx ON events (created_at);-- Bad: cast on the column → Filter over the entire index, no Index CondEXPLAIN (COSTS OFF) SELECT count(*) FROM events WHERE created_at::date = '2025-02-01';-- Good: half-open range on the raw column → Index CondEXPLAIN (COSTS OFF) SELECT count(*) FROM eventsWHERE created_at >= '2025-02-01' AND created_at < '2025-02-02';
-- bad -- goodParallel Index Only Scan using events_created_idx Index Only Scan using events_created_idx Filter: ((created_at)::date = '2025-02-01'::date) Index Cond: ((created_at >= …) AND (created_at < …))
Same for: lower(col), date_trunc(…, col), col + 1 = 5, coalesce(col, 0) = 0 — rewrite or add an expression index
2. Implicit Casts / Type Mismatch
-- user_id is int; a numeric literal casts the COLUMN to numeric → index unusableEXPLAIN (COSTS OFF) SELECT * FROM events WHERE user_id = 42.0;-- Filter: ((user_id)::numeric = 42.0) ← Parallel Seq ScanEXPLAIN (COSTS OFF) SELECT * FROM events WHERE user_id = 42; -- index used
Real-World Causes: ORMs binding bigint vs numeric, comparing varchar to text[], joining int to bigint across tables (fine) vs int to text (not fine), UUIDs stored as text
3. Leading Wildcard LIKE
-- B-tree can serve a prefix match only with text_pattern_ops (or C collation)CREATE INDEX events_email_pattern_idx ON events (email text_pattern_ops);EXPLAIN (COSTS OFF) SELECT * FROM events WHERE email LIKE 'User4211%'; -- Index Scan (range on prefix)-- Leading % can't use a B-tree at all → Seq ScanEXPLAIN (COSTS OFF) SELECT * FROM events WHERE email LIKE '%4211@%';-- Fix: trigram GIN indexCREATE EXTENSION IF NOT EXISTS pg_trgm;CREATE INDEX events_email_trgm_idx ON events USING gin (email gin_trgm_ops);EXPLAIN (COSTS OFF) SELECT * FROM events WHERE email LIKE '%4211@%'; -- Bitmap Index Scan on events_email_trgm_idx
4. OR Across Columns
Problem: WHERE a = 1 OR b = 2 needs an index on both columns (combined with BitmapOr); if one side is unindexed the whole query falls back to a Seq Scan
Fixes: Index both columns, rewrite as UNION, or use IN (…) / = ANY for same-column ORs
-- amount has no index → Parallel Seq Scan for the whole OREXPLAIN (COSTS OFF) SELECT * FROM events WHERE user_id = 42 OR amount > 498;-- Both sides indexed → BitmapOr of two index scansEXPLAIN (COSTS OFF) SELECT * FROM events WHERE user_id = 42 OR email = 'User1@Example.com';-- UNION rewrite (each branch uses its own best plan)EXPLAIN (COSTS OFF)SELECT * FROM events WHERE user_id = 42UNIONSELECT * FROM events WHERE email = 'User1@Example.com';
5. Low Selectivity
Rule of Thumb: If a predicate matches more than ~5–20% of the table, a Seq Scan is cheaper than random index lookups — the planner is right to ignore your index
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE kind = 'view'; -- 79% of rows → Seq ScanEXPLAIN (COSTS OFF) SELECT * FROM events WHERE kind = 'refund'; -- 1% → Index Scan using events_refunds_idx (the partial index)
Every write updates every index; blocks HOT updates
Drop unused (pg_stat_user_indexes.idx_scan = 0)
Stale statistics
Bad row estimates → bad plans
ANALYZE; tune autovacuum
count(*) on huge tables for UI
Full scan under MVCC
Estimate from pg_class.reltuples
Planner Statistics Tools
-- What the planner believes about a columnSELECT attname, n_distinct, null_frac, most_common_valsFROM pg_statsWHERE tablename = 'events' AND attname IN ('kind', 'user_id');-- Correlated columns: teach the planner they move togetherCREATE STATISTICS IF NOT EXISTS events_kind_amount (dependencies) ON kind, amount FROM events;ANALYZE events;-- Session-level experiments (never in production config)SET enable_seqscan = off;EXPLAIN (COSTS OFF) SELECT * FROM events WHERE kind = 'view' LIMIT 1;RESET enable_seqscan;
Interview Questions
“Index exists but isn’t used — why?”: Function/cast on the column, type mismatch, leading wildcard, low selectivity, stale stats, OR with an unindexed column
“EXPLAIN vs EXPLAIN ANALYZE?”: Estimate only vs actually execute and report real rows/time (careful with writes)
“Composite index column order?”: Equality filters first, then range/sort column; leftmost prefix rule
“What is a covering index?”: Index containing every column the query needs (INCLUDE) → index-only scan
“Add an index to a busy production table?”: CREATE INDEX CONCURRENTLY, outside a transaction, check for INVALID afterwards
“Index a JSONB column?”: GIN (jsonb_path_ops for @>), or a B-tree expression index on (data->>'key')
Summary
EXPLAIN ANALYZE + BUFFERS: Compare estimated vs actual rows, watch Rows Removed by Filter, loops and Sort spills
Index Toolkit: B-tree, composite, INCLUDE covering, partial, expression, GIN (jsonb/trgm), BRIN for time series
Production: CONCURRENTLY for create/reindex/drop; clean up INVALID indexes
Anti-patterns: Functions/casts on columns, implicit type casts, LIKE '%x', mixed-column ORs, low selectivity
Balance: Every index speeds reads but slows writes — measure with pg_stat_user_indexes (Admin and Inspection Queries)