PostgreSQL EXPLAIN ANALYZE & Query Optimization - Complete Notes

A practical and interview-focused guide to reading query plans, diagnosing wrong estimates, understanding scan and join strategies, controlling memory, and improving slow SQL with evidence.

00. The query-plan mental model

SQL describes the result you want. PostgreSQL's planner chooses an executable tree of operations that can produce it. Query optimization is the discipline of comparing that prediction with what execution really did, finding the largest mismatch or waste, and fixing its cause.

A single SQL statement can be executed in many equivalent ways. PostgreSQL can scan a table directly or through an index, start from either side of a join, and combine rows with a nested loop, hash join or merge join. The planner estimates the cost of possible paths from table statistics, configuration, indexes and query structure. It then chooses the plan with the lowest estimated cost, not the plan with the most indexes or the shortest-looking output.

Think of route planning. The SQL statement gives a destination. Statistics are the map and traffic model. Cost settings describe how expensive different roads seem. The plan is the chosen route, and EXPLAIN ANALYZE is the trip record. If the map says 10 cars use a road but 100,000 actually appear, the route choice can be reasonable for the estimate and still be terrible in reality.

Question Plan evidence Typical response
Did PostgreSQL expect the right number of rows? Estimated rows versus actual rows × loops Refresh or improve statistics; fix predicates or data modeling
Where was time spent? Actual node time, loops, I/O timing and execution time Reduce repeated work or expensive input
Was unnecessary data read? Buffers, rows removed, heap fetches and scan conditions Improve access path, predicate, index or selected columns
Did an operation spill? Sort Method, Disk, hash Batches and temporary buffers Reduce rows/width or test a scoped memory change
Was the join strategy suitable? Join node, child sizes, order, loops, sorts and batches Fix estimates and access paths before forcing a method
The planner is usually rational about the information it has

A bad plan is often a symptom of wrong row estimates, missing access paths, an expression that cannot use an index, inadequate memory, or a query that asks for too much work. Disabling a plan type can hide the symptom without correcting the model.

01. EXPLAIN, EXPLAIN ANALYZE and safe syntax

Plain EXPLAIN shows the planner's prediction without executing the statement. EXPLAIN ANALYZE executes it and adds measurements. That difference is operationally important.

Useful starting forms
-- Prediction only. Safe for inspecting a write because it is not executed.
EXPLAIN
SELECT * FROM orders WHERE customer_id = 42;

-- Execute and compare estimates with reality.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;

-- Add I/O timing when track_io_timing is enabled.
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT customer_id, SUM(total)
FROM orders
WHERE ordered_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY customer_id;

-- Machine-readable plan for tooling and stable parsing.
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders WHERE order_id = 1001;
Option What it adds When it helps
ANALYZE Executes the statement; reports actual rows, time and loops Validating estimates and locating execution work
BUFFERS Shared, local and temporary block activity Separating CPU work from cache and storage work
WAL WAL records, full-page images and bytes Understanding write amplification for modifying statements
SETTINGS Plan-relevant settings that differ from built-in defaults Making plan experiments reproducible
VERBOSE Output expressions, qualified object names and more detail Tracing columns through complex plans
TIMING OFF Disables per-node clock calls but still counts rows Reducing instrumentation overhead on timing-sensitive systems
SUMMARY Planning and execution summary Controlling concise output
FORMAT JSON Structured output instead of display-oriented text Automation, comparison and plan visualization tools
ANALYZE really runs the statement

A data-changing statement will change data, fire triggers, wait for locks and generate WAL. Plain EXPLAIN is prediction-only. To measure a reversible write, use an explicit transaction and ROLLBACK, but remember that external side effects from volatile functions or non-transactional systems might not roll back.

Measure a write without keeping its database changes
BEGIN;

EXPLAIN (ANALYZE, BUFFERS, WAL)
UPDATE orders
SET status = 'archived'
WHERE status = 'cancelled'
  AND ordered_at < CURRENT_DATE - INTERVAL '1 year';

ROLLBACK;

Run performance tests with production-like data distribution and row counts. A one-page test table often gets a sequential scan because reading that page directly is cheaper than traversing an index. The same query can get a different plan after the table grows, statistics change, cache state changes, parameters differ, or a PostgreSQL version changes.

02. Reading a plan as a tree

A plan is a pull-based tree. Parent nodes request rows from their indented child nodes. Read from the deepest children upward to understand how rows are produced, then use the top node for the statement-level result.

Illustrative text plan
Hash Join  (cost=85.00..960.00 rows=2400 width=48)
           (actual time=1.200..12.900 rows=2510 loops=1)
  Hash Cond: (o.customer_id = c.customer_id)
  -> Seq Scan on orders o
       (cost=0.00..810.00 rows=2400 width=32)
       (actual time=0.020..10.100 rows=2510 loops=1)
       Filter: (status = 'paid')
       Rows Removed by Filter: 27490
  -> Hash
       (cost=60.00..60.00 rows=2000 width=16)
       (actual time=1.100..1.100 rows=2000 loops=1)
       Buckets: 2048  Batches: 1  Memory Usage: 110kB
       -> Seq Scan on customers c
            (cost=0.00..60.00 rows=2000 width=16)
            (actual time=0.010..0.650 rows=2000 loops=1)
Planning Time: 0.400 ms
Execution Time: 13.200 ms
  1. The bottom customer scan supplies 2,000 rows to Hash, which builds an in-memory lookup table.
  2. The order scan reads 30,000 rows, removes 27,490 by its filter, and emits 2,510 paid orders.
  3. The hash join probes the customer hash with those orders and emits 2,510 joined rows.
  4. Estimates are close here, and the hash uses one batch, so the first question is whether reading all 30,000 orders is acceptable for the observed latency and query frequency.

Index Cond, Recheck Cond, Filter and Join Filter

Label Meaning Diagnostic clue
Index Cond Condition used to navigate or restrict index entries The index is doing search work, not merely being scanned
Recheck Cond Condition verified against heap rows after a bitmap match Expected for bitmap scans; lossy pages can increase rechecks
Filter Condition applied after a node obtains candidate rows Large rows removed can expose wasted reads or a missing index condition
Hash Cond or Merge Cond Equality condition that drives the join algorithm Defines the keys used to match the two inputs
Join Filter Additional condition evaluated after candidate pairs meet Many removed pairs can be expensive, especially inside loops
Node figures include child work

A parent's elapsed range covers work performed while it pulls from its children. Do not add every node time because that double-counts nested work. To approximate exclusive time, compare a parent with its children carefully, account for loops, and use buffers and I/O evidence.

03. Costs, rows, width, actual time and loops

Every number answers a different question. Costs are planner estimates in arbitrary units. Actual times are measured milliseconds. Never compare a cost number directly with milliseconds.

Anatomy of one node
Index Scan using orders_customer_id_idx on orders
  (cost=0.43..18.77 rows=6 width=72)
  (actual time=0.025..0.090 rows=120 loops=50)
Field Meaning How to interpret it
Startup cost 0.43 Estimated work before the first row can be returned High for blocking nodes such as a full sort or hash build
Total cost 18.77 Estimated work if the node runs to completion Planner compares alternatives using cost, adjusted for LIMIT-like demand
Estimated rows 6 Rows emitted by one execution of this node Not necessarily rows scanned or filtered internally
Width 72 Estimated average bytes per emitted row Wide rows increase memory, copying, sorting and hash-table size
Actual time 0.025..0.090 Average time to first row and completion per loop Multiply by loops for an approximate total across executions
Actual rows 120 Average rows emitted per loop Total output is approximately 120 × 50 = 6,000
Loops 50 Number of times the node was executed Often one inner execution per outer row in a nested loop

In this example, PostgreSQL expected about 6 rows per index lookup but received about 120, a 20 times underestimate. Across 50 loops, the node emitted roughly 6,000 rows rather than the roughly 300 implied by the estimate. That multiplicative error can make a nested loop look cheap during planning and expensive during execution.

What cost represents

Cost combines estimated page access and CPU work using settings such as seq_page_cost, random_page_cost, cpu_tuple_cost and cpu_operator_cost. The scale is relative. Its purpose is to rank candidate plans on the configured system, not predict wall-clock duration.

  • A lower estimated cost can still run slower if estimates are wrong.
  • Warm-cache and cold-cache executions can have the same plan but different times.
  • A fast node repeated 500,000 times can dominate a query.
  • A LIMIT can stop a child early, so its actual rows may be below full-run estimates.
  • Parallel-node row and loop reporting requires attention to leader and worker activity.

04. Sequential, index, index-only and bitmap scans

A scan strategy is not a quality label. It is an access path chosen for an estimated fraction of the table, required order, row width, physical locality and cost model.

Sequential Scan

A Seq Scan reads table pages in physical order and tests each visible row. It is often best when the query needs a large fraction of the table, the table is small, no usable index exists, or index-driven random heap visits would cost more than reading the table once.

A sequential scan can be the correct plan
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, customer_id, total
FROM orders
WHERE status IN ('paid', 'shipped');

Seq Scan on orders
  Filter: (status = ANY ('{paid,shipped}'::text[]))
  Rows Removed by Filter: 3800
  Buffers: shared hit=920

If 80 percent of rows qualify, an index on status may add traversal and scattered heap access without avoiding much table work. Investigate a sequential scan when selectivity is expected to be high, the table is large, execution is frequent, or the estimate is far from the actual result. Do not investigate it only because its name contains "sequential."

Index Scan

An Index Scan walks matching index entries and fetches corresponding heap tuples. It excels for selective predicates, ordered retrieval and small result sets. Heap access can be scattered because PostgreSQL indexes are separate from the heap.

Selective lookup that also supplies order
CREATE INDEX orders_customer_ordered_idx
    ON orders (customer_id, ordered_at DESC);

EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, ordered_at, total
FROM orders
WHERE customer_id = 42
ORDER BY ordered_at DESC
LIMIT 20;

Look for the useful predicate under Index Cond. A condition only shown under Filter may be evaluated after candidate entries are fetched. Column order, operator class, casts, collation, expressions, partial-index predicates and parameter types all affect whether a condition can become an index condition.

Index Only Scan

An Index Only Scan is possible when one index can supply every value the query needs. PostgreSQL still has to check MVCC visibility. It can skip the heap only for pages marked all-visible in the visibility map, so Heap Fetches is the critical measurement.

Cover a frequent read
CREATE INDEX orders_customer_cover_idx
    ON orders (customer_id, ordered_at DESC)
    INCLUDE (order_id, total);

EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, ordered_at, total
FROM orders
WHERE customer_id = 42
ORDER BY ordered_at DESC
LIMIT 20;

-- A healthy, mostly static table may report:
-- Index Only Scan using orders_customer_cover_idx on orders
--   Heap Fetches: 0

Frequent writes clear all-visible bits until vacuum can set them again. An index-only plan with many heap fetches can therefore behave closer to an ordinary index scan. Adding payload columns also enlarges the index and write cost, so covering indexes should target measured queries.

Bitmap Index Scan and Bitmap Heap Scan

A bitmap plan separates matching from fetching. One or more Bitmap Index Scan nodes collect tuple locations. PostgreSQL can combine them with BitmapAnd or BitmapOr. A Bitmap Heap Scan then visits heap pages in physical order and rechecks conditions.

Middle-selectivity access path
Bitmap Heap Scan on orders
  Recheck Cond: (customer_id = 42)
  Heap Blocks: exact=310 lossy=0
  -> Bitmap Index Scan on orders_customer_id_idx
       Index Cond: (customer_id = 42)

Bitmap scans often win between the extremes of a few random index fetches and a whole-table sequential scan. They reduce scattered heap I/O but do not preserve index order, so an ORDER BY may still require a sort.

Lossy does not mean incorrect

When the bitmap cannot retain exact tuple locations within its memory allowance, it can record page-level matches. The heap scan then checks all rows on those lossy pages against Recheck Cond. Results remain correct, but extra CPU and heap work appear as lossy blocks and rows removed by index recheck.

Access path Typical strength Important evidence
Seq Scan Large fraction, compact table, bulk read Buffers and rows removed
Index Scan Selective or ordered retrieval Index Cond, heap buffers, loops
Index Only Scan Covered read on mostly all-visible pages Heap Fetches and buffers
Bitmap Scan Moderate result set or combined indexes Exact/lossy blocks, recheck removals

05. Nested loop, hash and merge joins

Join choice depends on input sizes, estimated matches, available order, indexes, operators and memory. The same pair of tables can correctly use different algorithms for different parameters.

Nested Loop

A nested loop obtains a row from its outer child and runs or probes the inner child for that row. It is excellent when the outer input is small and the inner side has a cheap parameterized index lookup. It is dangerous when the outer side is much larger than estimated or the inner work is expensive.

Efficient selective nested loop
Nested Loop
  -> Index Scan using customers_pkey on customers c
       Index Cond: (customer_id = 42)
  -> Index Scan using orders_customer_id_idx on orders o
       Index Cond: (customer_id = c.customer_id)

Inspect the inner node's loops. An inner lookup taking 0.05 ms seems cheap, but 200,000 loops imply about 10 seconds of repeated work before accounting for other nodes. PostgreSQL may add Memoize to cache repeated parameterized inner results when outer keys repeat.

Hash Join

A hash join builds a hash table from one input, usually the smaller one, then probes it with rows from the other input. It supports hashable equality conditions and is strong for unsorted, medium-to-large inputs. Startup cost includes building the hash.

Read hash diagnostics
Hash Join
  Hash Cond: (o.customer_id = c.customer_id)
  -> Seq Scan on orders o
  -> Hash
       Buckets: 131072  Batches: 8  Memory Usage: 6145kB
       -> Seq Scan on customers c

Batches: 1 normally means the hash table fit in its memory budget. Multiple batches mean partitioning and temporary-file work. A spill is a clue, not an automatic command to raise memory. First ask whether fewer or narrower rows should reach the hash and whether the build-side estimate was wrong.

Merge Join

A merge join consumes both inputs in join-key order and advances through matching ranges. It is useful for large inputs, equality and supported ordered comparisons, especially when indexes or earlier nodes already provide the required order. Otherwise one or both sides may need a sort.

Merge join with ordered inputs
Merge Join
  Merge Cond: (o.customer_id = c.customer_id)
  -> Index Scan using orders_customer_id_idx on orders o
  -> Index Scan using customers_pkey on customers c

Merge joins handle long ordered streams predictably and can be attractive when output order is useful later. Added sort nodes increase startup work and may spill. As with a LIMIT, a merge join can stop reading one child when no later match is possible, so an apparently low actual row count for that child is not always an estimation error.

Join Best common shape Main risk
Nested loop Small outer side and cheap indexed inner lookup Unexpectedly large outer input multiplies inner work
Hash join Equality join over unsorted medium/large inputs Wrong build size or insufficient memory causes batches
Merge join Large inputs already ordered by join keys Sorting wide or large inputs can dominate and spill
Outer join names and outer input are different ideas

The outer input of a nested loop is the child read first and shown first. This execution term does not mean the SQL statement uses LEFT OUTER JOIN. A regular inner join also has an outer and inner input in a nested-loop plan.

06. Sorts, aggregates, limits and parallel nodes

Scans and joins are only part of a plan. Supporting nodes explain blocking work, memory demand, repeated evaluation, row reduction and parallel coordination.

Node Purpose What to inspect
Sort Orders rows for ORDER BY, merge, DISTINCT or another parent Sort Method, memory or disk, input width and rows
Incremental Sort Sorts within groups when input is already partly ordered Presorted key and full-sort/pre-sorted group details
Aggregate Computes grouped or global aggregates over ordered input Input order, rows and upstream sort
HashAggregate Stores groups in hash tables Batches, memory and disk usage
Limit Stops after the requested number of rows Whether child order permits stopping early
Materialize Stores child output for efficient rereads Why the parent needs repeated access and how large it is
Memoize Caches parameterized results by lookup key Hits, misses, evictions, overflows and memory
Gather Combines rows from parallel workers without preserving order Workers planned/launched and per-worker output
Gather Merge Merges ordered parallel streams Worker sorts, ordering and leader work
Top-N and full sort are materially different
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, total
FROM orders
ORDER BY total DESC
LIMIT 10;

-- A plan may show:
-- Sort Method: top-N heapsort  Memory: 26kB
--
-- Without LIMIT it may need:
-- Sort Method: external merge  Disk: 184320kB

Push row reduction before expensive sorts and hashes when semantics allow it, and avoid carrying unused wide columns through them. SELECT * can increase width enough to change a join or spill decision. The optimizer already pushes many safe predicates, but query boundaries, volatile expressions and forced materialization can prevent transformations.

07. Estimated rows versus actual rows

Row-count errors are often more important than node-time differences because estimates determine plan choice. Errors early in a tree propagate into join order, join strategy, memory sizing and downstream cardinality.

Measure the ratio at each important node

Use order-of-magnitude thinking
estimate ratio = actual rows per loop / estimated rows

estimated rows = 100, actual rows = 120       -- 1.2x, usually close
estimated rows = 100, actual rows = 12,000    -- 120x underestimate
estimated rows = 50,000, actual rows = 20     -- 2500x overestimate

Exact equality is not expected. Sampling, changing data and inherently uncertain predicates make estimates approximate. Focus on large factors at the lowest node where the mismatch first appears. A parent mismatch may simply inherit a child's error.

Common causes of estimation errors

  • Stale statistics: many writes occurred since auto-analyze or manual ANALYZE.
  • Skew: a rare tenant and a huge tenant share one column, but the statistics do not represent the important value precisely enough.
  • Correlated columns: city and postal code, country and currency, or status and archived date are not independent.
  • Expression predicates: the planner lacks statistics for the expression or the query hides the base distribution.
  • Cross-table relationships: ordinary statistics are table-local and do not know every semantic relationship across a join.
  • Generic prepared plans: one plan is reused without a specific parameter value, which can be poor for highly skewed values.
  • Uncertain patterns: complex functions, wildcards, user-defined operators and data-dependent conditions may require fallback selectivity assumptions.

Fix statistics at the right level

Refresh and enrich planner knowledge
-- Refresh ordinary statistics after a representative data change.
ANALYZE orders;

-- Collect a larger sample for a highly skewed column.
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 500;
ANALYZE orders;

-- Teach PostgreSQL about correlation and combined distinct counts.
CREATE STATISTICS orders_status_date_stats
    (dependencies, mcv, ndistinct)
    ON status, ordered_at
    FROM orders;
ANALYZE orders;

-- Expression statistics can help matching expression predicates.
CREATE STATISTICS orders_month_stats
    ON (date_trunc('month', ordered_at))
    FROM orders;
ANALYZE orders;

Higher statistics targets increase sample size and catalog detail, which can improve estimates for irregular distributions at the cost of more analysis time and statistics storage. Do not raise the global target blindly. Target the columns and expressions where plan evidence shows a meaningful error.

CREATE STATISTICS does not collect data by itself

It declares a statistics object. Run ANALYZE, or wait for auto-analyze, to populate it. Extended statistics can capture dependencies, most-common-value combinations and combined distinct counts within one table. They do not become indexes and do not speed row access directly.

08. Buffers, I/O timing, rows removed and heap fetches

Runtime alone says a query was slow on one execution. Resource evidence explains what it asked the server to do and is often more stable across cache states.

Understanding buffer fields

Field Meaning Important caution
shared hit Requested block was already in PostgreSQL shared buffers A hit still consumes CPU and is not free
shared read Block had to be brought into shared buffers It may come from OS cache, not necessarily physical storage
dirtied Previously clean block was changed by the operation Writing it later can occur outside the measured statement
written Block was written during this operation Can expose checkpoint or buffer-pressure effects
temp read/written Temporary-file block activity Often indicates sort, hash or materialization spill
I/O Timings Measured read/write wait when tracking is enabled Timing collection has overhead and depends on configuration

Buffer counts at a parent include its descendants. Compare related nodes rather than adding every line. A query with millions of shared hits can be slow even with zero reads because it repeatedly walks cached pages. A query with many reads but low I/O time may be benefiting from the operating system cache.

Rows removed

Rows Removed by Filter, Rows Removed by Join Filter and Rows Removed by Index Recheck are average per-loop values when loops exceed one. Multiply before judging total waste. Large removal is meaningful only in context:

  • A one-time sequential scan removing 90 percent may be correct for a reporting query.
  • An inner scan removing 1,000 rows across 50,000 loops is likely a serious repeated cost.
  • Rows removed by index recheck on lossy bitmap pages point to memory pressure and page rechecks.
  • A highly selective filter applied late may belong earlier or in an index condition if semantics permit.

Heap Fetches

For Index Only Scan, heap fetches show how often PostgreSQL still visited the table to verify visibility. High values can come from recently modified pages or delayed vacuuming. They do not prove the index definition is wrong. Check table churn, vacuum health and whether index-only access is a realistic goal for the workload.

09. work_mem effects without memory accidents

work_mem is a base limit for one sort, hash table or similar executor operation before temporary files are used. It is not a per-query or per-connection memory cap.

One query may contain several sorts and hashes, and many sessions may run them concurrently. Parallel workers can each receive operation-level budgets. Hash operations use a limit derived from work_mem × hash_mem_multiplier. Therefore, changing a global value from 4 MB to 256 MB does not mean each connection can consume only 256 MB. Worst-case demand can be much larger.

Use a transaction-local experiment
BEGIN;
SET LOCAL work_mem = '64MB';

EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT customer_id, SUM(total)
FROM orders
GROUP BY customer_id
ORDER BY SUM(total) DESC;

ROLLBACK;

Reading sort evidence

Memory and disk sort methods
-- Fits in memory:
Sort Method: quicksort  Memory: 18340kB

-- Uses a bounded top-N algorithm:
Sort Method: top-N heapsort  Memory: 27kB

-- Spills to temporary files:
Sort Method: external merge  Disk: 145920kB

A disk sort can improve with more memory, but also test whether an index can supply the required order, whether a LIMIT enables top-N work, whether fewer rows can reach the sort, and whether unused wide columns can be removed. A large disk figure is evidence for this execution, not a universally safe global setting.

Reading hash evidence

Hash nodes report buckets, batches and peak memory. One batch normally means the hash structure fit in memory. Multiple batches mean data was partitioned, with temporary I/O. Wrong build-side estimates can cause unexpected batching even when configuration seemed adequate.

Do concurrency arithmetic before changing the default

Estimate active expensive queries, memory-consuming nodes per query, parallel processes, and hash multipliers. Prefer scoped SET LOCAL changes for a known report or role when possible. Monitor temporary-file activity and system memory under realistic concurrency, not only one successful session.

10. A repeatable optimization workflow

Optimization is an experiment with a baseline, a hypothesis, one controlled change and a new measurement. Random index creation and planner switches make results hard to explain or retain.

  1. Define the slow workload. Capture the exact SQL, parameters, frequency, expected result, latency objective, concurrency and PostgreSQL version.
  2. Make the test representative. Use realistic row counts, value skew, schema, indexes, statistics and configuration. Preserve correctness tests.
  3. Take a baseline. Record EXPLAIN (ANALYZE, BUFFERS, SETTINGS), execution time, relevant system load and more than one run when safe.
  4. Read bottom-up. Note scan conditions, child sizes, join order, loops, sorts, hashes and parallel workers.
  5. Find the first cardinality surprise. Compare estimated and actual rows per loop, starting at leaves. Correct stale, skewed or correlated statistics first.
  6. Find the largest resource waste. Inspect cumulative buffers, I/O timing, repeated loops, filtered rows, heap fetches, temporary blocks and spills.
  7. Form one hypothesis. Examples: a selective predicate lacks a matching index; two columns are correlated; a generic plan ignores parameter skew; a wide sort spills.
  8. Make the smallest durable change. Rewrite a predicate, add a targeted index, refresh statistics, add extended statistics, reduce selected columns, or apply scoped memory.
  9. Measure again. Compare plan shape, rows, loops, buffers, spill evidence and end-to-end latency. Include write cost and other parameter values.
  10. Keep or revert using evidence. Document the reason and monitor production behavior. Plans can change as data and software evolve.
Fix the earliest cause, not the loudest downstream symptom

If a leaf estimate is wrong by 1,000 times, a downstream nested loop, spill and bad join order may all follow from it. Adding memory or disabling nested loops might improve one example while leaving the planner misinformed for every related query.

11. Worked diagnosis: ignored index and wrong estimate

An available index is only one candidate path. Diagnose why the chosen path was cheaper under the planner's estimates and whether those estimates matched reality.

Query and initial plan
SELECT order_id, customer_id, ordered_at, total
FROM orders
WHERE status = 'cancelled'
  AND ordered_at >= CURRENT_DATE - INTERVAL '7 days';

Seq Scan on orders
  (cost=0.00..182000.00 rows=40 width=40)
  (actual time=0.030..880.000 rows=185000 loops=1)
  Filter: ((status = 'cancelled') AND
           (ordered_at >= (CURRENT_DATE - '7 days'::interval)))
  Rows Removed by Filter: 4815000

The first major fact is not simply "Seq Scan." PostgreSQL expected 40 output rows and received 185,000, an underestimate of more than 4,000 times. If recently cancelled orders are strongly concentrated in the recent date range, treating the two predicates as independent can create that error.

  1. Run ANALYZE orders if statistics may be stale, then remeasure.
  2. Inspect whether each single-column estimate is reasonable and whether the combination is the failure.
  3. Create multivariate most-common-value or dependency statistics for (status, ordered_at), run ANALYZE, and remeasure.
  4. If this frequent query genuinely needs 185,000 rows, calculate whether transferring and processing that result is itself the bottleneck.
  5. Evaluate a composite or partial index whose column order and predicate match the workload, then include its write and storage cost.
Possible statistics and access-path changes
CREATE STATISTICS orders_status_ordered_mcv (mcv, dependencies)
    ON status, ordered_at
    FROM orders;
ANALYZE orders;

-- Candidate only after measuring query frequency and write cost:
CREATE INDEX orders_cancelled_ordered_idx
    ON orders (ordered_at)
    WHERE status = 'cancelled';

The index may still be ignored if most cancelled rows fall in the seven-day range, table pages are small and cached, the partial predicate cannot be proven from a parameterized condition, or returning four columns requires many scattered heap visits. The corrected estimate lets the planner compare those realities more honestly.

12. Worked diagnosis: nested loop explosion

Nested loops become painful when a supposedly small outer stream is large and the inner operation repeats for every outer row.

Recognize multiplication
Nested Loop
  (cost=1.00..4200.00 rows=200 width=64)
  (actual time=0.080..9200.000 rows=240000 loops=1)
  -> Seq Scan on events e
       (cost=0.00..3000.00 rows=100 width=32)
       (actual time=0.020..310.000 rows=120000 loops=1)
       Filter: (event_type = 'purchase')
  -> Index Scan using users_pkey on users u
       (cost=0.43..12.00 rows=1 width=32)
       (actual time=0.040..0.070 rows=2 loops=120000)
       Index Cond: (user_id = e.user_id)

The event scan is the first large mismatch: 100 estimated outer rows versus 120,000 actual. The inner scan then runs 120,000 times. Its reported 0.070 ms completion time is an average per loop, so its total contribution is approximately 8.4 seconds.

  • Refresh and improve statistics for event_type and related predicates.
  • Verify uniqueness assumptions if the primary-key lookup unexpectedly returns two rows.
  • After estimates improve, PostgreSQL may choose a hash join without any hint.
  • Reduce outer rows early if the requested result permits it.
  • Do not globally disable nested loops; they remain ideal for genuinely selective lookups.

13. Prepared plans, parameters and partition pruning

The plan observed for a literal is not always the plan an application uses. Prepared statements can use parameter-specific custom plans or a reusable generic plan.

Inspect a prepared statement
PREPARE recent_orders (bigint) AS
SELECT *
FROM orders
WHERE customer_id = $1
ORDER BY ordered_at DESC
LIMIT 50;

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE recent_orders(42);

A custom plan knows the supplied value. A generic plan avoids repeated planning but cannot tailor estimates to a specific value. That tradeoff matters when customer 42 has five orders and customer 99 has five million. Compare the plan used through the application path, not only an equivalent literal in a SQL console. plan_cache_mode can support diagnosis, but forcing a mode globally is rarely the first fix.

Partition pruning evidence

For partitioned tables, inspect whether irrelevant partitions disappear. Plans can show Subplans Removed or child nodes marked never executed. Pruning may occur during planning or execution depending on when parameter values become known. Missing pruning can come from mismatched expressions, types or partition keys, and it can multiply scan overhead.

14. Measurement caveats and common mistakes

Plans are measurements from a particular environment and execution. Good diagnosis preserves that context and avoids conclusions that the evidence cannot support.

Mistake Why it fails Better approach
Assume every sequential scan is bad Bulk reads and small tables often favor it Compare fraction read, buffers, result size and alternatives
Compare cost with milliseconds Cost is a relative planner unit Compare cost to cost and measured time to measured time
Add all node times Parent time includes child work Read tree nesting, loops and resource counters
Ignore loops Actual time and rows are averages per execution Multiply by loops for approximate total work
Test only with warm cache It can hide storage demand Report cache state and compare buffers/I/O under relevant conditions
Run ANALYZE on a write casually The statement executes and side effects occur Use plain EXPLAIN or a carefully controlled rollback
Increase work_mem globally from one spill Budget multiplies across operations, workers and sessions Reduce work and test scoped memory under concurrency
Force scan or join types permanently It treats a symptom and harms other parameter shapes Use enable flags only for comparison; fix estimates or access paths
Optimize a toy data set Plan choices change nonlinearly with size and distribution Use representative volume, skew and statistics
Trust one fast execution Cache, concurrency and background work vary Repeat safely and inspect resource evidence plus percentiles

EXPLAIN ANALYZE overhead and missing client cost

Per-node timing adds instrumentation overhead, especially for nodes executed many times. TIMING OFF keeps row and loop counts while reducing clock-call overhead. Also, ordinary EXPLAIN ANALYZE does not send result rows to the client, so network transfer and normal output conversion are not represented in the same way as application latency. Wide, large result sets can therefore be slow for users even when server execution looks acceptable.

Locks and concurrency

A plan explains executor strategy, not every source of elapsed latency. Lock waits, CPU saturation, storage queueing, connection-pool waits and client backpressure can dominate. Correlate plans with database activity and system metrics. Never conclude that identical plans must have identical production latency.

15. Performance implications of common fixes

Every optimization spends a resource or adds operational responsibility. Evaluate the complete workload, not only the improved SELECT.

Change Possible benefit Tradeoff to measure
Add an index Selective access, useful order, index-only opportunity Write latency, WAL, storage, cache pressure and vacuum work
Add INCLUDE columns Cover a frequent read Larger index and limited benefit on write-heavy pages
Raise statistics target Better skew and histogram estimates Longer ANALYZE and larger statistics data
Add extended statistics Better multi-column or expression estimates Analysis overhead and maintenance of targeted objects
Raise work_mem Fewer temporary spills Multiplied memory and out-of-memory risk under concurrency
Rewrite SQL Earlier reduction or a usable predicate Semantic changes, null behavior and maintainability
Materialize intermediate results Avoid repeated expensive computation Lost predicate pushdown, storage and stale intermediate data
Partition a table Prune large unrelated regions and simplify lifecycle tasks Planning overhead, key constraints and operational complexity
End-to-end optimization includes correctness and stability

A rewrite that changes null semantics is not an optimization. An index that speeds one report but overloads the write path is not a complete win. A memory setting that works for one session but exhausts the server at peak concurrency is unsafe. Keep changes only after testing their full effect.

16. Practice problems

For each problem, state the evidence you would collect before proposing a fix. Several answers can be valid when they are supported by measurement.

1. A query on a 50-row lookup table uses a sequential scan even though a primary-key index exists. Is that a bug?

Not necessarily. The table may occupy one or very few pages, making a direct scan cheaper than traversing an index and fetching the heap. Verify actual buffers and latency. Do not force an index merely to change the node name.

2. An inner index scan reports 0.02 ms, 4 rows and 300,000 loops. Estimate its output and why it matters.

It emits about 1.2 million rows in total and spends roughly 6 seconds if 0.02 ms is its average completion time per loop. Inspect the outer-side estimate, repeated keys, Memoize suitability and whether a different join becomes attractive after cardinality is corrected.

3. A bitmap heap scan shows 20 exact blocks, 8,000 lossy blocks and many rows removed by index recheck. What happened?

The bitmap stored page-level rather than exact tuple locations for many pages, commonly because of its memory allowance. PostgreSQL rechecked rows for correctness. Reduce candidate rows and test a scoped work_mem change while checking total concurrency memory.

4. A hash join reports eight batches. Should work_mem be raised globally?

Not from that fact alone. Confirm temporary I/O, build rows and width, estimate accuracy, query frequency, simultaneous memory nodes and concurrent sessions. Reduce input or width first, then test a transaction-local budget and calculate fleet-wide risk.

5. Filtering on country and currency is underestimated because the planner treats them as independent. What is a targeted fix?

Create appropriate extended statistics on the two columns, often dependencies and/or MCV, analyze the table, and compare the new estimates. An index may improve access but does not by itself teach the planner the joint distribution.

6. An index-only scan has almost as many heap fetches as rows. What should you inspect?

Inspect table write churn, vacuum progress, all-visible coverage and buffers. The index covers the values, but PostgreSQL still visits heap pages whose visibility-map bits are not set.

7. A literal query is fast in psql but the prepared application statement is slow for one large tenant. What is the next experiment?

Explain the prepared execution with representative parameter values and compare custom and generic behavior in a controlled session. Parameter skew can make one reusable generic plan unsuitable for extreme tenants.

8. A plan has a 900 MB external sort before returning 10 rows. Give three hypotheses.

The required order may lack a supporting index, LIMIT may be separated from the sort by query semantics, or excessive rows and wide columns may reach the sort. Test each hypothesis without assuming memory is the only solution.

17. Interview Q&A

Q: What is the difference between EXPLAIN and EXPLAIN ANALYZE?

EXPLAIN shows the planner's estimated plan without running it. EXPLAIN ANALYZE executes the statement and adds actual rows, time, loops and other runtime details. Because it executes, writes and side effects really happen unless safely controlled.

Q: How do you read a PostgreSQL plan?

Read the most-indented child nodes first and work upward. At each node, identify its input, output, condition, estimated versus actual rows, loops and resource evidence. Find the earliest large estimate mismatch and the largest repeated or I/O-heavy work.

Q: What do the two cost numbers mean?

They are estimated startup cost and total cost in arbitrary planner units. Startup cost is work before the first row, while total cost assumes the node runs to completion. They rank plans and are not milliseconds.

Q: Why must rows and actual time be multiplied by loops?

When a node executes multiple times, PostgreSQL displays average actual rows and time per execution. Multiplying by loops approximates total output and total time for that node. This is essential for inner nodes of nested loops.

Q: Sequential scan versus index scan?

A sequential scan reads table pages in physical order and is efficient for large fractions or small tables. An index scan finds matching index entries then visits heap tuples, which is efficient for selective or ordered retrieval but can create scattered access.

Q: When does PostgreSQL choose a bitmap scan?

Commonly for a moderate number of matches or when combining indexes. It builds a bitmap of tuple locations, then visits heap pages in physical order. Under memory pressure it can become lossy and recheck rows on matching pages.

Q: What makes an index-only scan truly avoid heap access?

One index must contain all required values, and matching heap pages must be marked all-visible so PostgreSQL can verify MVCC visibility from the visibility map. EXPLAIN ANALYZE reports heap fetches when table visits are still required.

Q: Nested loop versus hash join versus merge join?

Nested loop is strong for a small outer input and cheap inner lookups. Hash join is strong for unsorted equality joins over larger inputs. Merge join is strong when large inputs are already ordered or sorting is otherwise worthwhile. Estimates determine which shape appears cheapest.

Q: How do row-estimate errors cause slow plans?

Cardinality drives join order, scan choice, join algorithm and expected memory. An underestimate can select repeated nested-loop probes or undersize a hash. An overestimate can discourage a useful index path or favor unnecessary bulk processing.

Q: How do you fix correlated-column estimates?

After checking freshness, create targeted extended statistics such as dependencies or multivariate most-common values on the related columns, run ANALYZE and remeasure. Ordinary single-column statistics often assume conditions are independent.

Q: What does work_mem control?

It is the base memory limit for each sort, hash table and similar executor operation before temporary-file work. It is not a whole-query cap. Multiple nodes, parallel processes and concurrent sessions multiply potential usage, while hashes also use hash_mem_multiplier.

Q: What is the first thing you do with a slow query?

Capture the exact query, representative parameters, context and baseline EXPLAIN (ANALYZE, BUFFERS, SETTINGS). Then locate the earliest large row-estimate error and the dominant repeated, buffer-heavy or spilling operation before proposing one measured change.

Q: Why might PostgreSQL ignore an available index?

The result may be too large, the table may be small, heap fetches may be expensive, statistics may predict low selectivity, the predicate may not match the index expression or leading columns, an implicit cast or collation may interfere, or a partial-index predicate may not be provable. An index is an option, not a command.

Q: Why avoid disabling planner methods as a permanent fix?

It restricts alternatives for every affected query and often hides wrong estimates or missing access paths. Method switches are useful experiments: if forcing an alternative helps, ask what information made its estimated cost lose and fix that cause.

18. One-screen cheat sheet

  • EXPLAIN: estimate only; statement is not executed.
  • EXPLAIN ANALYZE: executes and measures; writes have real effects.
  • Read: deepest children first, then move upward.
  • Cost: relative planner units, not milliseconds.
  • rows: output per execution, not necessarily rows inspected.
  • loops: multiply actual rows and time to estimate total work.
  • width: estimated bytes per row; wide rows enlarge memory and I/O.
  • Seq Scan: often correct for large fractions and small tables.
  • Index Scan: selective/ordered access plus heap visits.
  • Index Only: inspect Heap Fetches and visibility-map coverage.
  • Bitmap: middle selectivity; lossy pages require rechecks.
  • Nested Loop: small outer input plus cheap inner lookup.
  • Hash Join: equality join; multiple batches indicate spill work.
  • Merge Join: ordered inputs; inspect sort cost and spill.
  • Buffers: parent counts include children; hits still cost CPU.
  • Estimate error: find the first large mismatch in the tree.
  • Statistics: ANALYZE freshness, skew target, extended correlations.
  • work_mem: per operation, multiplied by nodes/workers/sessions.
  • Spill: first reduce rows and width, then test scoped memory.
  • Workflow: baseline, hypothesis, one change, remeasure, monitor.

19. Official reference map

Last reviewed · July 2026 · part of knowledge-base