Database Indexing: B-Trees, Composites, and EXPLAIN
A selective lookup that once scanned most of a large table can become an index walk plus a few heap fetches — often the difference between a snappy API and a timeout. Indexes are the highest-impact performance tool most application developers control, and the easiest to misuse: add them reactively, never drop unused ones, and ignore column order.
This article explains B-tree mechanics, which columns deserve indexes, composite leftmost-prefix rules, how to read EXPLAIN ANALYZE, and mistakes that hurt writes. Soften any war stories with your own EXPLAIN — do not trust borrowed speedup ratios.
Index strategy starts with the storage model, so compare SQL and NoSQL databases. If repeated reads still dominate after query tuning, evaluate the Redis and CDN caching strategies.
How B-Tree Indexes Work
A B-tree keeps indexed values in a balanced tree; leaf entries point at table rows (heap tuples in PostgreSQL). Lookups cost roughly logarithmic tree height, not a full scan of every row. The planner chooses sequential scan vs index scan by estimated cost: highly selective predicates favor indexes; queries that touch most of the table often favor a sequential pass (random I/O from bouncing through the index can be worse).
Query trace: API GET /orders?user_id=123 → SQL WHERE user_id = $1 → planner picks Index Scan on idx_orders_user_id → fetch matching heap rows → JSON response. Without the index, the same request becomes a Seq Scan — fine on tiny tables, painful as row counts grow.
Quick reference
- B-tree: default; supports =, ranges, BETWEEN, LIKE 'prefix%', ORDER BY.
- Hash: equality only — limited use versus B-tree in modern Postgres.
- GIN: arrays, JSONB, full-text inverted lookups.
- GiST: geometry, some text/spatial (PostGIS) patterns.
- BRIN: bulky naturally ordered columns (e.g. time) with small index size.
- Every index adds write work on INSERT/UPDATE/DELETE.
Remember this
B-tree is the default; pick GIN/GiST/BRIN only when the access method matches the type.
Which Columns to Index
Index columns that appear in hot WHERE, JOIN, or ORDER BY clauses on large tables. High-cardinality columns (many distinct values) benefit most. A three-value status column alone is usually a poor standalone index — selectivity is too low.
Foreign keys are commonly missed: PostgreSQL does not auto-index FK columns, yet JOINs and cascades scan them constantly. When not to index: tiny tables, write-heavy columns never filtered, or “just in case” indexes you never verified with EXPLAIN.
Quick reference
- High selectivity → good index candidate (user_id, email, uuid).
- Low selectivity alone → often skip (boolean, tiny enums).
- Always consider indexing foreign keys.
- Partial index: WHERE status = 'active' for hot subsets.
- Expression index: lower(email) for case-insensitive match.
- pg_stat_user_indexes: idx_scan = 0 after real traffic → drop candidate.
Remember this
Index hot filters and foreign keys; skip low-selectivity vanity indexes.
Composite Indexes and Column Order
A composite B-tree on (a, b, c) is most efficient when predicates constrain leading columns. PostgreSQL can still use later-column conditions, and PostgreSQL 18 added skip-scan plans that can help when omitted leading columns have few distinct values, but that is cost-based rather than guaranteed. Put equality columns before a range when that matches the hot query shape.
Failure path: you index (status, created_at) but the dashboard filters only created_at. The planner may choose a sequential scan; PostgreSQL 18 may choose skip scan when status has low cardinality. Verify the real plan before adding a dedicated index.
Quick reference
- Leading constraints are usually most efficient; PostgreSQL 18 skip scan can sometimes make later-only predicates useful.
- Equality before range in the same composite.
- INCLUDE columns enable index-only scans for hot narrow queries.
- Put ORDER BY columns at the end when the query sorts the same way.
- Verify with EXPLAIN — never assume column order from instinct.
Remember this
Respect leftmost prefix; equality before range; confirm with EXPLAIN.
Reading EXPLAIN ANALYZE
EXPLAIN ANALYZE runs the query and shows actual timings, row counts, and loops. Compare estimated vs actual rows — large gaps mean stale statistics (ANALYZE) or a bad plan. Seq Scan on a large table returning few rows usually means a missing or defeated index (function on the column, type mismatch, leading-wildcard LIKE).
Treat timings as local evidence for your data and hardware. Re-run before/after on the same machine; do not cite another blog’s “N× faster” as your result.
Quick reference
- EXPLAIN = estimate; EXPLAIN ANALYZE = real run (careful on prod writes).
- actual time and loops: multiply to see total node cost.
- Rows Removed by Filter high → missing/non-sargable predicate.
- Buffers hit vs read: cache behavior and I/O pressure.
- Run ANALYZE after bulk loads so estimates stay honest.
Remember this
Diagnose with EXPLAIN ANALYZE on your data — estimate vs actual and Seq Scan smells first.
Four Indexing Mistakes That Kill Performance
Over-indexing slows writes: each INSERT/UPDATE/DELETE maintains every index. Unused indexes still cost disk, backups, and planner confusion. Classic defeats: function on an indexed column without a matching expression index, implicit type mismatch, leading-wildcard LIKE on a B-tree, and OR across unrelated columns.
When not to add another index: you have not seen Seq Scan or slow actual time on a production-shaped dataset, or idx_scan shows an existing index already unused.
Quick reference
- Profile first; index second.
- Drop zero-scan indexes after steady traffic (with a backup plan).
- LIKE 'prefix%' can use B-tree; '%middle%' needs trigram/GIN or FTS.
- OR → often UNION ALL of indexed legs.
- CREATE INDEX CONCURRENTLY avoids blocking ordinary writes but still takes brief locks, waits on transactions, and cannot run inside a transaction block.
- If a concurrent build fails, inspect and drop/rebuild the INVALID index; it is unusable for queries but can still add update overhead.
- Partial indexes shrink size when filters are stable.
Remember this
Remove unused indexes, match expression indexes to predicates, and verify every new index with EXPLAIN.
Key takeaway
Indexes trade write overhead for read speed. Add the minimum set that fixes measured plans: foreign keys, hot filters, composites with correct order, expression indexes when predicates wrap columns. Audit unused indexes on a schedule.
Practice (25 min): Take one slow list/detail query from your app. Capture EXPLAIN (ANALYZE, BUFFERS) before changes. Add one index (or fix a defeating predicate), re-run on the same database, and record estimated vs actual rows plus the chosen node type. Bonus: query pg_stat_user_indexes for a zero-scan index and decide keep vs drop with one sentence of evidence.
Related Articles
Explore this topic