Scaling Databases & Data Consistency · Finding the Bottleneck

Indexing for Real Workloads

Composite column order, covering and partial indexes, and the write cost of every index you add.

Scenario

Example app: an order system with these hot queries:

  1. WHERE customer_id = ? AND status = 'open' ORDER BY created_at DESC (customer dashboard)
  2. WHERE status = 'pending' AND created_at < now() - interval '15 min' (abandoned-checkout job; 0.5% of rows are pending)
  3. SELECT total FROM orders WHERE id = ? (very frequent)

What you see in production

Over the years the orders table collected 14 indexes, several on single columns. Reads are still slow, and checkout inserts became 4× slower than a year ago — each insert updates 14 indexes.

The naive code

One index per column "just in case":

CREATE INDEX ON orders (customer_id);
CREATE INDEX ON orders (status);
CREATE INDEX ON orders (created_at);
-- … 11 more

Reproduce it

On the sample shop, compare a single-column index with a composite one that matches filter and sort, and a covering index that answers the query from the index alone:

Composite and covering indexes in the planTry it yourself
CREATE INDEX idx_status ON orders (status);
EXPLAIN QUERY PLAN SELECT order_date FROM orders WHERE customer_id = 1 AND status = 'delivered' ORDER BY order_date;
CREATE INDEX idx_cust_status_date ON orders (customer_id, status, order_date);
EXPLAIN QUERY PLAN SELECT order_date FROM orders WHERE customer_id = 1 AND status = 'delivered' ORDER BY order_date;

Output

id | parent | notused | detail
4 | 0 | 0 | SEARCH orders USING INDEX idx_status (status=?)
15 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY

id | parent | notused | detail
3 | 0 | 0 | SEARCH orders USING COVERING INDEX idx_cust_status_date (customer_id=? AND status=?)

Root cause

  • A query typically uses one index per table access; three single-column indexes rarely combine as well as one composite index built for the query.
  • Column order matters: equality filters first, then range or sort columns. (customer_id, status, created_at) serves query 1 completely; (created_at, customer_id) doesn't.
  • Every index is written on every insert and on updates to indexed columns — 14 indexes means 15 writes per order, more WAL and bigger replicas lag.

The fix

Design indexes from the query list, then drop the rest:

-- 1: dashboard — equality, equality, sort
CREATE INDEX CONCURRENTLY orders_customer_status_created ON orders (customer_id, status, created_at DESC);
-- 2: tiny partial index for the background job (0.5% of rows)
CREATE INDEX CONCURRENTLY orders_pending_created ON orders (created_at) WHERE status = 'pending';
-- 3: covering lookups (index-only scan) — INCLUDE adds payload columns without ordering them
CREATE INDEX CONCURRENTLY orders_id_total ON orders (id) INCLUDE (total);
-- find unused indexes (since stats reset)
SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;

Trade-offs

  • Partial indexes are tiny and fast for skewed predicates, but only used when the query repeats the same WHERE condition.
  • Covering indexes enable index-only scans but grow the index; Postgres index-only scans also depend on the visibility map (VACUUM).
  • Low-cardinality columns (status with 5 values) are poor leading columns unless the query also filters something selective — or the value you search for is rare (partial index).
  • Removing an index is cheap to undo but check replicas and rare monthly jobs before dropping "unused" ones.

Detect it in production

  • pg_stat_user_indexes.idx_scan = 0 over a full business cycle → candidate for removal.
  • Duplicate prefixes: (a) is redundant next to (a, b).
  • Insert latency and WAL volume per table; replica lag growing with write volume.

Key points

  • Index for queries, not for columns: equality first, then range/sort.
  • Use partial indexes for rare values and covering indexes for hot lookups.
  • Every index costs writes, memory and replication; remove unused and redundant ones.

Practice questions

Which index serves `WHERE customer_id = ? AND status = ? ORDER BY created_at DESC` best?

Answer

A composite index on (customer_id, status, created_at DESC): both equality filters narrow to one range, and rows come out in sort order so no extra sort is needed.

When is a partial index a good choice?

Answer

When queries target a small, fixed subset of rows (e.g. status = 'pending', 0.5% of the table). The index is small and fast, and only used by queries that repeat the same condition.

Why can adding indexes make checkout slower?

Answer

Each insert (and update to indexed columns) must also update every index: more I/O, more WAL, more replication traffic and more lock work. Unused or redundant indexes cost writes without helping reads.

Open in the app to mark lessons complete, take notes and track your progress.