Postgres performance and index health
Slow query patterns, missing and unused indexes, table bloat, and connection posture on a live Postgres database.
Scope
A read-only performance review of one Postgres database using the statistics
collector and catalog views. SELECT and EXPLAIN only — never
EXPLAIN ANALYZE (it executes the statement), never CREATE INDEX,
VACUUM, REINDEX, or pg_stat_reset(). Every recommendation is a proposal
for a human to schedule, because index builds and vacuums have production cost.
Procedure
- Establish the baseline and the window.
SELECT version(), thenSELECT * FROM pg_stat_database WHERE datname = current_database()forstats_reset,xact_commit/xact_rollback,blks_hit/blks_read(cache hit ratio: below ~0.99 on an OLTP database usually meansshared_buffersis too small for the working set),deadlocks, andtemp_files/temp_bytes(temp file volume meanswork_memis too small for the sorts and hashes actually running). Every counter below is cumulative sincestats_reset, so state that date in the report — a "top query" over three hours and over three months are different claims. Check whetherpg_stat_statementsis installed:SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements'. - Query patterns. With
pg_stat_statements: rank bytotal_exec_time DESCfor aggregate load, and separately bymean_exec_time DESC(filtered tocallsabove a floor such as 100, so a single slow migration does not top the list) for per-call pain. Those two columns are namedtotal_timeandmean_timebefore Postgres 13, so use theversion()read in step 1 — or the column list ofpg_stat_statementsitself — and fall back to the old names on 12 and earlier rather than letting the query fail on a missing column. For the top patterns readcalls,rows,shared_blks_hitvsshared_blks_read, andtemp_blks_written. A statement with enormouscallsand a tinymean_exec_timeis usually an N+1 in the application, and it can outweigh every genuinely slow query on the list — check for that shape explicitly. Without the extension, fall back topg_stat_activitysampling pluspg_stat_user_tables.seq_scanandseq_tup_read, and say in the report that query-level attribution was unavailable. - Explain the worst offenders. For the top few statements, run plain
EXPLAIN(neverANALYZE) on the parameterized form with representative literals. Look for sequential scans on large tables, nested loops over large estimated row counts, and estimates that are wildly off the table's real size — the last usually means stale statistics, which is alast_analyzequestion, not an index question. - Indexes, both directions. From
pg_stat_user_indexesjoined topg_index: any index withidx_scan = 0sincestats_resetis dead weight — it is written on every insert and update and read by nobody. Exclude unique and primary-key indexes and any index backing a constraint or a foreign key, which exist for correctness rather than reads; call that exclusion out in the report so nobody drops a constraint index on this checkup's advice. Usepg_relation_size()to size each candidate so the report can rank them. In the missing direction, usepg_stat_user_tables: highseq_scanwith highseq_tup_readon a large table, andidx_scannear zero, is a table being scanned for want of an index. Also detect exact-duplicate and left-prefix-redundant indexes (an index on(a)is redundant when(a, b)exists) by comparingindkeycolumn lists per table. Check for unused foreign-key columns with no supporting index, which makes cascading deletes and joins scan. - Bloat and autovacuum. From
pg_stat_user_tables:n_dead_tupagainstn_live_tupper table. A ratio above ~20% on a large, actively-read table is real bloat — it inflates every scan and the buffer cache. Readlast_vacuum,last_autovacuum,last_analyze,last_autoanalyze, andautovacuum_count: a table with a largen_dead_tupand alast_autovacuumof null or weeks old means autovacuum is not keeping up, usually because of per-tableautovacuum_vacuum_scale_factordefaults on a very large table. Check for long-lived transactions inpg_stat_activityholding back the vacuum horizon (see the next step) — they are frequently the actual cause of bloat, and recommending a manualVACUUMwithout finding them fixes nothing. - Connection posture.
SELECT count(*), state FROM pg_stat_activity GROUP BY stateagainstSHOW max_connections. Flag: total connections above ~70% ofmax_connections(no headroom for a failover or a deploy); any session inidle in transactionfor more than a few minutes (it pins the vacuum horizon and can hold locks);idle in transaction (aborted)sessions at all. Readstate_change,xact_start,wait_event_typeandwait_eventfor the worst offenders, andbackend_type/application_nameto attribute them to a service. Check whether a pooler is in front: hundreds of mostly-idle application connections is a pooling finding, not amax_connectionsfinding. - Locks, only if the evidence points there. If step 6 shows waiting sessions,
read
pg_locksjoined topg_stat_activityto identify the blocking chain. Report the pattern (which statement blocks which), not a one-off snapshot. - Attribute everything. Name the view behind every number, and give the
stats_resetwindow once at the top of the report. Where the connected role lacks visibility (pg_stat_statementsnot installed, other users' query text hidden withoutpg_read_all_stats), say what that leaves unverified.
Finding keys
The key names the underlying issue so the ledger can track it across runs:
the same unused index must produce the same key every month, so a run where it is
absent means it was actually dropped. Use
<object-type>:<schema-qualified-object>:<issue-slug>, and never embed a timing,
a row count, or a date — those change every run and belong in detail and
evidence.
index:public.users_email_idx:unusedtable:public.orders:bloattable:public.events:missing-index(ortable:public.events:seq-scan-heavy)query:3f8a91c4:slow— thepg_stat_statements.queryid, not the textconnection:api-service:idle-in-transactionconfig:max_connections:no-headroom
Severity rubric
- critical — the database is at or near a hard limit right now: connections
above 90% of
max_connections, transaction-ID wraparound risk visible inage(datfrozenxid), or a blocking chain stalling user-facing writes. - high — one query pattern responsible for more than a quarter of total
execution time; a large hot table with autovacuum demonstrably not keeping up;
an
idle in transactionsession hours old; cache hit ratio well below 0.99 on an OLTP workload. - medium — a large unused index worth dropping; a missing index on a table
taking heavy sequential scans; 20–40% dead-tuple ratio; measurable temp-file
volume pointing at
work_mem. - low — small unused or duplicate indexes; stale
ANALYZEon a low-traffic table; cosmetic schema issues with no measured cost. - info — healthy readings worth recording as a baseline for the next run, or a new large table to watch.
Output guidance
Open with a one-paragraph executive summary: the statistics window
(stats_reset date), overall health in plain words, and the one change with the
largest expected effect. Then sections for Query patterns, Indexes, Bloat and
autovacuum, and Connections and locks, each naming the view behind its numbers.
Close with a recommendations table (Recommendation | Object | Expected effect |
Production risk | When to run it) — index builds and vacuums carry real cost, so
every row must state its risk and whether it needs a maintenance window. Never
present a proposed index as free. State outright when the database is healthy.
Run it against your systems
This checkup is in the desktop app under Checkups. No card, read-only credentials you configure.