Overview

Run EXPLAIN (ANALYZE, BUFFERS) to see real row counts, timings, and cache hits; BUFFERS is included with ANALYZE by default from Postgres 18. ANALYZE executes the statement, so wrap writes in BEGIN; ... ROLLBACK;. The reasoning behind tuning lives in postgres-explain; this card is the node lookup.

Scan nodes

Expect a Seq Scan only when most rows are needed.

NodeChosen when
Seq ScanNo usable index, or most rows are needed.
Index ScanSelective predicate, or the index supplies the sort order.
Index Only ScanThe index covers every column; Heap Fetches counts visits when the visibility map is stale (run VACUUM).
Bitmap Index Scan + Bitmap Heap ScanMedium selectivity; collects index hits, then reads heap pages in order.
Tid ScanDirect ctid lookup; rare in application code.
Function Scan, Values Scan, CTE ScanSet-returning function, VALUES list, materialized CTE.

Join, sort, and aggregate nodes

Match the join node to input sizes and sort order.

NodeMeaning
Nested LoopFor each outer row, probe the inner side; good when the inner side is indexed.
Hash JoinBuild a hash of one input, probe with the other; limited by work_mem.
Merge JoinBoth inputs sorted on the join key.
SortSort Method: external merge Disk: ... means it spilled past work_mem.
Incremental SortSorts within presorted prefixes.
HashAggregate / GroupAggregateHashed grouping / streaming over sorted input.
WindowAggWindow functions; usually sits on a Sort.
MaterializeCaches an inner side for reuse, typically under a Nested Loop.
MemoizeCaches inner-side results per key under a Nested Loop (Postgres 14+).
Gather, Gather MergeParallel workers feed the leader.

Reading the numbers

Compare estimated and actual rows before anything else.

FieldMeaning
cost=0.42..1.85Estimated cost to the first row, then the last row (arbitrary units; compare, do not sum).
rows= / width=Estimated rows and bytes per row.
actual time=0.01..1.23 rows=128 loops=12Measured per-loop values; multiply by loops for the node total.
Buffers: shared hit=12 read=38 kB pages from cache / from disk.
Rows Removed by FilterRows read and discarded; a large count means a weak or missing index.

Symptoms and fixes

Fix statistics before adding indexes when estimates are far off.

SymptomLikely causeFix
Seq Scan on a big table with a selective filterMissing index, or a function on the columnAdd an index; make the predicate sargable or index the expression.
Estimated rows far from actual rowsStale or skewed statisticsANALYZE table; consider CREATE STATISTICS or a higher default_statistics_target.
Sort Method: external mergework_mem too smallSET LOCAL work_mem = '64MB' for that query.
Nested Loop with large loops over a Seq ScanNo index on the inner join columnIndex it; Postgres has no planner hints.
High read and low hit in BuffersCold cache or a very large readRun twice; narrow the query or add a covering index.

Gotchas

Avoid these; each leads to wrong conclusions or unintended writes.

GotchaFix
Plain EXPLAIN shows estimates only.Use EXPLAIN ANALYZE to compare estimate and actual.
Per-node timing adds overhead on hosts with slow clocks.Try EXPLAIN (ANALYZE, TIMING OFF).
Bulk loads leave statistics stale until autovacuum catches up.Run ANALYZE after loading.