awesome-everything RU
↑ Back to the climb

Databases

Production failure modes and the index audit playbook

Crux The seven most common index failures in production — missing FK index, type coercion, stale stats, bloat, wrong composite order, JSONB cardinality bomb — plus the quarterly audit playbook and per-environment index strategy.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 18 min

A DELETE FROM posts WHERE id = 42 with ON DELETE CASCADE takes 7 minutes and holds row-level locks the entire time. Root cause: comments(post_id) is not indexed. Postgres does a sequential scan of 200M comments to find the rows to delete. One missing index on a foreign key caused a production incident. This lesson catalogs the failure modes and the playbook to find them before they find you.

The seven failure modes

1. Missing index on FK column

Foreign-key columns are not automatically indexed. When you add REFERENCES posts(id), Postgres creates an index on posts(id) (the referenced column), but not on comments(post_id) (the referencing column). ON DELETE CASCADE on the parent table does a sequential scan of the child table to find rows to cascade to.

-- This is the dangerous pattern:
ALTER TABLE comments ADD CONSTRAINT fk_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE;
-- No index on comments(post_id) -- DELETE FROM posts is now O(n) on comments

-- Fix:
CREATE INDEX CONCURRENTLY idx_comments_post_id ON comments(post_id);

Rule: index every FK column unless you can prove the parent table is never deleted and no queries filter the child by the FK.

2. Implicit type coercion defeats the index

-- orders.user_id is BIGINT; index on orders(user_id) exists
SELECT * FROM orders WHERE user_id = '42';  -- '42' is TEXT

Postgres applies an implicit cast from TEXT to BIGINT — but to do so on every row, it cannot walk the B-tree (the cast is applied to the column, not the constant). Result: Seq Scan. Diagnosis: EXPLAIN ANALYZE on the query. Fix: use typed parameters ($1::BIGINT or the correct type in the query builder).

Other common coercion traps: created_at::date = '2024-01-01' defeats an index on created_at; use created_at >= '2024-01-01' AND created_at < '2024-01-02' instead.

3. Stale statistics cause wrong plan choice

The planner’s cost model depends on pg_statistic data — value distributions, row counts, correlations. After a bulk insert, large delete, or schema change, autovacuum schedules an ANALYZE, but by default only after 20% of rows change. Until ANALYZE runs, the planner has wrong estimates.

Symptom: EXPLAIN shows rows=50 but actual was rows=500,000 — a 10,000x miss. The planner picked a nested-loop with an index that is catastrophic at that row count.

Fix: run ANALYZE table_name after bulk data operations. For tables with highly skewed data (e.g., 90% of orders are for 5 workspaces), use CREATE STATISTICS (ndistinct) ON workspace_id, status FROM orders to give the planner better multi-column statistics.

4. Index bloat slows scans

Indexes accumulate dead entries from updates and deletes. Postgres 14+ does bottom-up index deletion (aggressive cleanup on hot leaf pages without waiting for vacuum), but heavy-update tables can still develop significant bloat over time.

Symptom: index size is 5-10x the expected size for the row count; queries are slow despite using the index.

-- Rebuild the index without blocking reads/writes
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

REINDEX CONCURRENTLY (Postgres 12+) builds a new index in the background and then swaps it in. Duration is similar to the initial CREATE INDEX CONCURRENTLY build.

5. Wrong composite column order

A composite index (status, workspace_id) where most queries filter by workspace_id alone does not accelerate those queries. The planner falls back to Seq Scan.

This is the leading-column rule violation discussed in lesson 02. In production, it often appears when an index was designed for one query but a second hot query was added later with a different filter pattern.

Fix: add a second index with the correct leading column, or redesign the composite if the original query shape is less common.

6. JSONB GIN cardinality bomb

A JSONB column where documents contain millions of unique keys creates a GIN index with millions of distinct posting-list entries. This index overwhelms shared_buffers (evicting hot heap pages) and has enormous write cost (updating the posting list for every unique key on each insert).

Symptom: GIN index size is 10-50x the underlying column size; shared_buffers cache-hit ratio drops; insert latency spikes after adding the GIN.

Fix: use expression B-tree indexes on known-hot fields instead of a GIN on the whole column:

-- Instead of: CREATE INDEX ON events USING GIN (payload)
-- Use:
CREATE INDEX ON events ((payload->>'event_type'));
CREATE INDEX ON events ((payload->>'user_id'));

This indexes only two known-hot fields with small B-tree indexes instead of a monolithic GIN.

7. “Has an index” but still slow

The most insidious failure: the right index exists, but the plan is still slow because:

  • The index is not covering (heap fetches dominate at scale).
  • The ORDER BY column is not in the index (full sort after index scan).
  • The index’s leading column does not match the most selective filter.

Diagnosis is always EXPLAIN (ANALYZE, BUFFERS) on the exact query with realistic parameters — not a contrived test query, not a staging environment with 100x fewer rows.

The quarterly index audit playbook

Run this playbook every quarter on every production Postgres database. It typically takes 1-2 hours and returns 10-30% storage savings plus measurable write-throughput improvements.

StepQuery / toolAction
1. Unused indexespg_stat_user_indexes WHERE idx_scan = 0DROP CONCURRENTLY after verifying not a constraint or batch-job index
2. Bloated indexespgstattuple_approx() on hot indexesREINDEX CONCURRENTLY on indexes with >30% bloat
3. Missing indexespg_stat_statements top-N by total_time + EXPLAINCREATE INDEX CONCURRENTLY for Seq Scans on large tables
4. Redundant indexespg_index catalog — find prefix duplicatesDROP CONCURRENTLY the shorter prefix when a composite covers it
5. VM healthpg_stat_user_tables.n_dead_tupVACUUM if n_dead_tup > 5% of live rows on IOS-dependent tables

Step 1: find unused indexes

SELECT
  schemaname,
  relname AS table_name,
  indexrelname AS index_name,
  idx_scan,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE '%_pkey'
ORDER BY pg_relation_size(indexrelid) DESC;

Caveats: (a) reset pg_stat_reset() happens on restart — if the server was recently restarted, the scan count is misleading; use a monitoring system that tracks these over time. (b) Some indexes are used only by periodic batch jobs — cross-reference pg_stat_statements over a longer window. (c) Unique indexes on constraint columns (UNIQUE, exclusion constraints) may show idx_scan = 0 but are still enforcing the constraint on every insert.

Step 2: find bloated indexes

-- Requires pgstattuple extension
SELECT
  indexrelid::regclass AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS total_size,
  round(100 * (approx_free_space + dead_tuple_len)::numeric
    / GREATEST(1, pg_relation_size(indexrelid)), 2) AS bloat_pct
FROM (
  SELECT indexrelid, (pgstattuple_approx(indexrelid::regclass)).*
  FROM pg_stat_user_indexes
) AS s
ORDER BY bloat_pct DESC;

Indexes with bloat_pct over 30% are candidates for REINDEX CONCURRENTLY. Schedule during off-peak.

Step 3: find missing indexes

Pull the top-20 queries from pg_stat_statements by total_exec_time. For each, run EXPLAIN (ANALYZE, BUFFERS) with realistic parameters. Look for Seq Scan on tables over 10k rows where the filter is selective (rows returned is small fraction of total). Each such query is a missing-index candidate.

Step 4: find redundant indexes

Two indexes are redundant when one’s key columns are a prefix of another’s. idx_on_a and idx_on_a_b — the idx_on_a is redundant if the idx_on_a_b composite is always used instead. Drop the shorter one.

-- Find potential duplicates (manual inspection required)
SELECT
  i.indexrelid::regclass AS index_name,
  array_to_string(array_agg(a.attname ORDER BY x.n), ', ') AS columns
FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = x.attnum
GROUP BY i.indexrelid, i.indrelid
ORDER BY i.indrelid, columns;

Index strategy across environments

Different environments have different needs:

Development: minimal indexes — only PKs and essential unique constraints. The goal is fast schema iteration, not query performance. Adding all production indexes slows migrations and obscures schema design (you should not lean on indexes to compensate for bad schema choices).

Staging: mirror production indexes when running load tests or benchmarking; skip them for pure functional testing. The staging index set helps catch “works in dev, slow in staging” before production.

Production: the full deliberate index set, with all the controls described in this unit — covering composites, partial indexes, INCLUDE, regular audits. Production index additions go through migration review alongside the query that requires them.

Replicas / analytics copies: may have additional OLAP-specific indexes (wide composites, BRIN on date ranges, GIN for full-text) that would be too expensive to maintain on the OLTP primary. If using logical replication to a dedicated analytics Postgres, add those indexes only on the replica.

Strategic posture

The single most common performance postmortem at production scale: “missing index on this hot query.” The second most common: “too many indexes slowing writes.” Both have the same root cause — indexes were not treated as part of the design.

Senior teams:

  • Add the index to the migration that ships the feature (query + index in one PR).
  • Review indexes in code review alongside the SQL.
  • Own index strategy at the platform level, not per-team.
  • Audit quarterly.

The cost of this discipline: one checklist item per PR touching SQL. The cost of not doing it: a 3am page when a customer dashboard times out because someone added a new FK last week without indexing the referencing column.

Index audit numbers
Audit cadence
quarterly
Typical storage savings from audit
10-30%
Typical write-rate improvement from dropping unused
2-5x on write-heavy tables
REINDEX CONCURRENTLY duration
similar to initial CREATE INDEX CONCURRENTLY
pgstattuple_approx bloat threshold for reindex
&gt;30%
idx_scan = 0 window (use monitoring, not just reset)
&gt;30 days
pg_stat_statements top-N for missing-index scan
top 20 by total_exec_time
FK column index: enforced by Postgres?
No — manual index required
Debug this

Query has an index but is still slow — diagnose

log
slow_query: SELECT id, total_cents FROM orders WHERE workspace_id = 42 AND status = 'pending' ORDER BY created_at DESC LIMIT 50;
execution_time: 4280 ms
rows_returned: 50

EXPLAIN ANALYZE:
Limit  (cost=320..380 rows=50 width=24) (actual time=4271..4280 rows=50 loops=1)
->  Sort  (cost=320..18420 rows=180000 width=24) (actual time=4270..4275 rows=50 loops=1)
      Sort Key: created_at DESC
      Sort Method: top-N heapsort  Memory: 32kB
      ->  Index Scan using idx_orders_workspace_status on orders
            (cost=0.42..18000 rows=180000 width=24) (actual time=0.02..3800 rows=178240 loops=1)
            Index Cond: ((workspace_id = 42) AND (status = 'pending'::text))

Index definitions:
idx_orders_workspace_status: btree (workspace_id, status)
idx_orders_workspace_status size: 1.2 GB
table size: 18 GB, row count: 80M

Statistics:
n_dead_tup: 12.4M (15% of total)
last_autovacuum: 14 days ago
last_analyze: 30 days ago

Why is the query 4280 ms despite using the index? What is the complete fix?

Trace it
1/5

Walk the full quarterly index audit on a 500 GB production Postgres database.

1
Step 1 of 5
Step 1: identify unused indexes.
2
Locked
Step 2: identify bloated indexes.
3
Locked
Step 3: identify missing indexes.
4
Locked
Step 4: identify redundant indexes.
5
Locked
Step 5: validate and monitor.
Quiz

A query runs: WHERE LOWER(email) = 'alice@x.com'. The table has an index on (email). Why does the query do a Seq Scan?

Quiz

A 200 MB index on a 2 GB table shows idx_scan = 0 for the last 45 days. What is the correct action?

lesson.inset.warning

Never drop an index just because idx_scan = 0. Always verify: (1) is it a unique/exclusion constraint index? (2) is it used by a periodic batch job that runs monthly or quarterly, outside the monitoring window? (3) was the server recently restarted, resetting all counters? Cross-reference pg_stat_statements over 30+ days and ask the application team before dropping.

Recall before you leave
  1. 01
    Name the seven production index failure modes and for each give the one-sentence diagnostic.
  2. 02
    Walk the quarterly index audit in five steps.
  3. 03
    What is the correct index strategy difference between development, staging, and production?
Recap

Seven failure modes cover the production index incidents: missing FK index (unindexed child column causes O(n) cascade scans); implicit type coercion (function on column disables index — use typed parameters); stale statistics (wrong plan from outdated row estimates — run ANALYZE after bulk ops); index bloat (REINDEX CONCURRENTLY on over-30% bloated indexes); wrong composite order (leading-column violation — redesign or add second index); JSONB GIN cardinality bomb (narrow to expression indexes on known-hot fields); and “has an index but still slow” (wrong structure — check EXPLAIN ANALYZE for Sort steps and Heap Fetches).

The quarterly audit: (1) DROP CONCURRENTLY unused indexes (idx_scan=0 for 30+ days, not constraints). (2) REINDEX CONCURRENTLY bloated indexes. (3) CREATE CONCURRENTLY missing indexes found via pg_stat_statements. (4) DROP CONCURRENTLY redundant prefix indexes. (5) Monitor for one week after changes.

Index strategy by environment: minimal in dev (fast iteration), mirror-production in staging for benchmarks, full deliberate set in production. OLAP-specific indexes belong on analytics replicas, not the OLTP primary.

Connected lessons
appears again in258
Continue the climb ↑Index design exercise: full-text search strategy
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.