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.
| Node | Chosen when |
|---|---|
Seq Scan | No usable index, or most rows are needed. |
Index Scan | Selective predicate, or the index supplies the sort order. |
Index Only Scan | The index covers every column; Heap Fetches counts visits when the visibility map is stale (run VACUUM). |
Bitmap Index Scan + Bitmap Heap Scan | Medium selectivity; collects index hits, then reads heap pages in order. |
Tid Scan | Direct ctid lookup; rare in application code. |
Function Scan, Values Scan, CTE Scan | Set-returning function, VALUES list, materialized CTE. |
Join, sort, and aggregate nodes
Match the join node to input sizes and sort order.
| Node | Meaning |
|---|---|
Nested Loop | For each outer row, probe the inner side; good when the inner side is indexed. |
Hash Join | Build a hash of one input, probe with the other; limited by work_mem. |
Merge Join | Both inputs sorted on the join key. |
Sort | Sort Method: external merge Disk: ... means it spilled past work_mem. |
Incremental Sort | Sorts within presorted prefixes. |
HashAggregate / GroupAggregate | Hashed grouping / streaming over sorted input. |
WindowAgg | Window functions; usually sits on a Sort. |
Materialize | Caches an inner side for reuse, typically under a Nested Loop. |
Memoize | Caches inner-side results per key under a Nested Loop (Postgres 14+). |
Gather, Gather Merge | Parallel workers feed the leader. |
Reading the numbers
Compare estimated and actual rows before anything else.
| Field | Meaning |
|---|---|
cost=0.42..1.85 | Estimated 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=12 | Measured per-loop values; multiply by loops for the node total. |
Buffers: shared hit=12 read=3 | 8 kB pages from cache / from disk. |
Rows Removed by Filter | Rows 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.
| Symptom | Likely cause | Fix |
|---|---|---|
Seq Scan on a big table with a selective filter | Missing index, or a function on the column | Add an index; make the predicate sargable or index the expression. |
Estimated rows far from actual rows | Stale or skewed statistics | ANALYZE table; consider CREATE STATISTICS or a higher default_statistics_target. |
Sort Method: external merge | work_mem too small | SET LOCAL work_mem = '64MB' for that query. |
Nested Loop with large loops over a Seq Scan | No index on the inner join column | Index it; Postgres has no planner hints. |
High read and low hit in Buffers | Cold cache or a very large read | Run twice; narrow the query or add a covering index. |
Gotchas
Avoid these; each leads to wrong conclusions or unintended writes.
| Gotcha | Fix |
|---|---|
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. |