Skip to main content

Command Palette

Search for a command to run...

PostgreSQL Plan Signatures: Quick Reference

A practical guide for PostgreSQL and TimescaleDB

Published
8 min readView as Markdown

PostgreSQL Plan Signatures: Quick Reference

Reading a PostgreSQL plan is usually a shape-recognition task, not an analytical one. Once you have seen enough of them, a Seq Scan on a 50-million-row table with a selective filter tells you what to do without thinking. Same with a Hash Join spilling to 17 batches, or a Sort going to external merge. You glance, you recognise, you fix.

This article is the cheatsheet for building that recognition. It is the reference companion to the Complete Guide to PostgreSQL SQL Query Analysis & Optimization series — the long-form articles explain why; this one gives you the pattern → fix mapping fast. Bookmark it, open it when you have an EXPLAIN output in front of you and a hunch to confirm.

Three tables below:

  1. Plan-node signatures — "when you see this, do that."
  2. SQL anti-patterns — "if your code looks like this, replace with that."
  3. MyDBA analyzer rules — severity, trigger condition, and the article that covers each.

If a row points to a deeper article, that's where the full explanation with captured EXPLAIN examples lives.

1. Plan-node signatures

A "signature" is a field or combination of fields you can spot in an EXPLAIN plan at a glance.

Plan signatureWhat it meansFixDeep dive
Seq Scan on table > 10k rows with selective filterMissing or unusable indexAdd an index on the filter column, or see non-sargable cases belowIndex usage
Seq Scan + Filter: fn(col) = ...Function on column disables indexNormalise on write, or add expression index ON t (fn(col))WHERE clause
Seq Scan with Rows Removed by Filter >> Actual RowsFilter running after scan instead of indexAdd index matching the filter; check if filter is sargableWHERE clause
Index Scan with large Heap Fetches:Index not covering SELECT listAdd INCLUDE columns to indexIndex usage
Index Only Scan with Heap Fetches: 0Optimal — the visibility map is workingNo action; keep autovacuum healthyReading EXPLAIN
Nested Loop with outer > 1,000 and no MemoizeQuadratic join on large inputsAdd index on inner join column; nested_loop_large ruleJoins
Hash Join with Batches > 1Hash table spilled to diskRaise work_mem per-session, or add index for different strategyJoins
Hash node Memory Usage > work_memWill spill on next executionSame: raise work_mem or change join strategyJoins
Sort with Sort Method: external mergeSort didn't fit in work_memRaise work_mem or add index providing sorted inputAggregate/window
Sort with Sort Method: top-N heapsortGood — only N rows kept in memoryNo action; verify LIMIT is reasonableReading EXPLAIN
HashAggregate with Batches > 1 or Disk Usage > 0Aggregate spillRaise work_mem or enable GroupAggregate with sorted indexAggregate/window
actual rows=r loops=l where l >> 1Node executed per outer-loop iterationUsually a nested loop or SubPlan; see whether rewrite is possibleJoins, Subquery/CTE
SubPlan N appearing under an outer nodeCorrelated subquery executed per rowRewrite as JOIN, aggregating JOIN, or LATERALSubquery/CTE
CTE ScanMaterialised CTE, predicates can't push inNOT MATERIALIZED if referenced onceSubquery/CTE
Plan Rows vs Actual Rows off by 10×+Stale statistics → bad planANALYZE table, consider extended statisticsReading EXPLAIN
Workers Planned > Workers LaunchedParallel-worker pool exhaustedRaise max_parallel_workers, check for contentionReading EXPLAIN
Lossy Heap Blocks > 50% on Bitmap Heap ScanBitmap exceeded work_mem, fell back to page-levelRaise work_memReading EXPLAIN
Gather / Gather Merge above every scanParallelism engaged; check worker count is optimalUsually fine; tune max_parallel_workers_per_gather if I/O-boundJoins
Buffers: shared read >> shared hit on hot pathWorking set doesn't fit in cacheRaise shared_buffers, check for too-small cacheReading EXPLAIN
Memoize with near-zero hitsCache isn't paying off (no repeated keys)No action; negligible costJoins
Run Condition: on WindowAggPG 15+ optimisation — window function short-circuitedWorking as intendedAggregate/window
Incremental Sort with Presorted Key:Partial index order let sort be localisedWorking as intended; cheaper than full sortAggregate/window

2. SQL anti-patterns and replacements

When you recognise one of these in code review, the replacement is usually a mechanical substitution.

Anti-patternWhy it's badReplacement
SELECT * from wide tableDisables Index Only Scan; bloats network payloadName the columns you actually need
WHERE text_col = 123Implicit cast on column disables indexWHERE text_col = '123'
WHERE lower(col) = 'x'Function on column disables indexNormalise on write, or CREATE INDEX ON t (lower(col))
WHERE col NOT IN (SELECT ...)NULL-unsafe; returns 0 rows on NULLsWHERE NOT EXISTS (SELECT 1 FROM ... WHERE ...)
WHERE col = 'a' OR col = 'b'Usually fine, but harder to index-planWHERE col IN ('a', 'b')
OFFSET N LIMIT M with large NReads and discards N rowsKeyset pagination with composite cursor
SELECT DISTINCT ... ORDER BY ... for top-1-per-groupAmbiguous — any row, not the firstSELECT DISTINCT ON (key) ... ORDER BY key, ...
Loop of one-row INSERTs1 round-trip per rowCOPY FROM STDIN, or multi-row VALUES, or INSERT ... SELECT
SELECT then INSERT upsertRace condition; two round tripsINSERT ... ON CONFLICT (col) DO UPDATE
DELETE FROM t WHERE old_date < ... on massive tableSingle huge lock; WAL storm; autovacuum blockedChunked loop of DELETE ... WHERE id IN (SELECT ... LIMIT 10000) with COMMIT per chunk
N+1 from ORM loops1 + N round trips; N plan-and-execute cyclesEager load with JOIN (joinedload, includes, prefetch_related)
count(*) on huge tablesFull table scanreltuples estimate, trigger-maintained counter, or redesign UI to not need the total
date_trunc('day', col) = '2024-01-15'Function disables index on colcol >= '2024-01-15' AND col < '2024-01-16'
Storing dates/numbers/booleans as textEvery type-aware query non-sargableALTER TABLE to the right type
WHERE clause with NOW() wrapped by user-defined function marked VOLATILERe-evaluated per rowMark UDF STABLE or IMMUTABLE if semantics allow
Transactions held open across external callsAutovacuum blocked, bloatFinish SQL before external calls; keep transactions short
SELECT ... FOR UPDATE on big rangesLocks every row returnedUse SELECT with SKIP LOCKED for worker queues; narrow the SELECT

3. MyDBA analyzer rules

The 15-rule first-pass analyzer in frontend/src/utils/explain-plan-analyzer.ts. Use this as a reference for what each rule means and where the full treatment lives in the series.

Rule IDSeverityTrigger conditionArticle
seq_scan_largewarning / criticalSeq Scan with Plan Rows > 10,000 (critical above 100,000)Index usage
excessive_filter_rowswarningRows Removed by Filter / Actual Rows > 10 and Rows Removed > 1000WHERE clause
nested_loop_largewarningNested Loop with outer > 1,000 and inner > 100 rowsJoins
sort_on_diskwarningSort Space Type = DiskAggregate/window
hash_batches_spillwarningHash Batches > 1 on Hash or Hash JoinJoins
row_estimate_inaccuratewarning / criticalactual_rows / plan_rows ratio > 10 or < 0.1 (critical at 100 / 0.01)Reading EXPLAIN
lossy_bitmap_scanwarningLossy Heap Blocks / Total > 50% on Bitmap Heap ScanReading EXPLAIN
cte_materializedinfoCTE Scan node presentSubquery/CTE
correlated_subplanwarningNode's JSON has a non-empty Subplan NameSubquery/CTE
parallel_workers_missinginfoWorkers Launched < Workers PlannedReading EXPLAIN
high_cache_miss_ratewarningshared_read / (read + hit) > 50% and read > 1000 blocksReading EXPLAIN
temp_blocks_writtenwarningTemp Written Blocks > 100Aggregate/window
very_high_total_costwarningTotal Cost > 1,000,000Reading EXPLAIN
deep_plan_treeinfoPlan has > 30 nodesReading EXPLAIN
no_index_usagewarningNo Index* nodes, at least one Seq Scan, > 2 total nodesIndex usage

Severity meanings:

  • critical: plan is almost certainly broken for this query size.
  • warning: plan has a specific, known problem; investigate.
  • info: worth noting, not necessarily actionable.

The analyzer runs on EXPLAIN plans in JSON format. If you paste a text-format plan into the MyDBA EXPLAIN visualiser, some rules (specifically the ones that rely on fields the text parser doesn't extract — such as Hash Batches or Temp Written Blocks) may not fire even when the underlying condition is present. Prefer capturing plans with EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT JSON) for the most reliable analysis.

How to use this reference

  1. Capture the plan with EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) in psql, or via the MyDBA EXPLAIN visualiser which captures it automatically.
  2. Scan for the dominant signature — the single biggest cost in the plan, usually the node with the highest actual time × loops.
  3. Look up the signature in table 1. If the fix points to a deeper article, read the relevant section there.
  4. Apply the fix. Almost all of them are single SQL statements or a single session GUC change.
  5. Re-run EXPLAIN. Verify the plan actually changed the way you expected. If it didn't, the fix was for a different root cause.

The entire workflow, end-to-end, is described in the pillar article. If the series has been useful, that's the single link to bookmark — everything else is deep-dive for specific categories.