| |

SQL 76 🛢️ Execution Plans and Query Optimization

⏱️ Estimated read: 21 min | |

A SQL query is a declaration of intent. The database is free to satisfy it however it chooses: scanning a table, walking an index, joining in one order or another, sorting in memory or on disk. The execution plan is the database’s chosen strategy, and reading it is the only reliable way to understand why a query is slow. The query optimizer, which produces the plan, is a cost-based decision engine. It estimates the cost of many possible plans and picks the cheapest one according to its statistics. When the estimates are wrong, the plan is wrong, and the query is slow. This chapter covers how plans are produced, how to read them, and how to intervene when the optimizer makes a bad choice.

Key point: The optimizer is not infallible. It decides based on statistics, and statistics are approximations of reality. A stale or misleading statistic produces a bad plan, and the developer’s job is to fix the statistic or rewrite the query so the estimate matches the truth.


Why execution plans matter

The black-box problem. A query that returns the right answer in ten milliseconds and a query that returns the right answer in ten minutes look identical in the source code. The difference is invisible until the plan is examined. Reading the plan turns performance tuning from guesswork into diagnosis.

The optimizer’s job. The optimizer receives a parsed query, generates candidate plans, estimates the cost of each, and chooses the cheapest. The cost model is based on statistics: row counts, distinct values, histograms, and index page counts. The optimizer does not execute the query; it reasons about it.

The cost estimation problem. Every estimate is an approximation. The optimizer assumes uniformity when the data is skewed, independence when columns are correlated, and accuracy when the statistics are current. When any of these assumptions fails, the estimate diverges from reality, and the plan is suboptimal.

The plan shapes problem. A plan can be a sequential scan, an index scan, an index-only scan, a bitmap scan, a nested loop join, a hash join, or a merge join. Each shape is appropriate for a different situation. The optimizer chooses based on cost, but the choice depends entirely on the estimates.

The regression problem. A query that was fast last week can be slow this week, with no change to the query. What changed is the data distribution, and therefore the statistics, and therefore the plan. This is the classic plan regression, and it is invisible without a plan comparison.

The portability problem. Every database produces plans in its own format. PostgreSQL uses EXPLAIN, MySQL uses EXPLAIN and EXPLAIN ANALYZE, SQL Server uses execution plans in SSMS, and Oracle uses EXPLAIN PLAN and DBMS_XPLAN. The concepts are shared; the syntax is not. The patterns in this chapter apply across all of them.


a. Reading an execution plan

An execution plan is a tree of operators. Each operator produces rows for its parent and consumes rows from its children. The root produces the query’s final result. The leaves read from tables or indexes.

The PostgreSQL EXPLAIN output

The EXPLAIN command shows the plan without executing the query. The EXPLAIN ANALYZE command executes the query and shows the actual row counts and timings alongside the estimates.

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
Seq Scan on orders  (cost=0.00..45000.00 rows=20 width=50)
  Filter: (customer_id = 42)

The output has three parts. The operator is Seq Scan on orders, which means the database will read every row of the orders table. The cost is 0.00..45000.00, where the first number is the startup cost and the second is the total cost, both in arbitrary units. The rows=20 is the estimated number of rows the operator will return, and width=50 is the estimated row width in bytes.

The EXPLAIN ANALYZE output

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Seq Scan on orders  (cost=0.00..45000.00 rows=20 width=50)
                   (actual time=0.015..850.000 rows=20 loops=1)
  Filter: (customer_id = 42)
  Rows Removed by Filter: 999980
Planning Time: 0.234 ms
Execution Time: 852.123 ms

The actual time is the real time in milliseconds, and actual rows is the real row count. The Rows Removed by Filter: 999980 shows that the scan read one million rows to return twenty. The loops=1 indicates the operator ran once.

The discrepancy between estimate and actual

The most important number in the plan is the difference between the estimated rows and the actual rows. A large discrepancy means the optimizer’s statistics are misleading, and the plan may be wrong.

Seq Scan on orders  (cost=0.00..45000.00 rows=20 width=50)
                   (actual time=0.015..850.000 rows=20 loops=1)

In this example, the estimate (rows=20) matches the actual (rows=20). The plan is correct, and the problem is that a sequential scan was chosen despite the filter returning few rows.

Seq Scan on orders  (cost=0.00..45000.00 rows=200000 width=50)
                   (actual time=0.015..850.000 rows=20 loops=1)

Here the estimate is rows=200000 and the actual is rows=20. The optimizer thought the filter would match many rows, so it chose a sequential scan. The estimate is wrong by four orders of magnitude. The fix is to update the statistics or rewrite the query.

The operator types

Each operator in the plan describes a different strategy.

OperatorMeaning
Seq ScanRead every row of the table
Index ScanWalk an index and fetch matching rows
Index Only ScanWalk an index without fetching the heap
Bitmap Index ScanBuild a bitmap of matching pages
Bitmap Heap ScanRead the pages from the bitmap
Nested LoopFor each row of the outer, scan the inner
Hash JoinBuild a hash table and probe it
Merge JoinMerge two sorted inputs
SortSort the input
AggregateCompute an aggregate
LimitStop after N rows

The plan tree

The plan is a tree, and the indentation shows the parent-child relationship. The root is at the top, and the leaves are at the bottom. Execution flows from the leaves to the root.

Hash Join  (cost=... rows=...)
  Hash Cond: (o.customer_id = c.id)
  ->  Seq Scan on orders o  (cost=... rows=...)
  ->  Hash  (cost=... rows=...)
        ->  Seq Scan on customers c  (cost=... rows=...)

The Hash Join is the parent. Its children are the Seq Scan on orders and the Hash, which in turn has the Seq Scan on customers as its child. The Hash operator builds a hash table from the customers scan, and the Hash Join probes that table for each row of the orders scan.


b. The cost model

The cost model is the set of rules the optimizer uses to estimate the cost of each operation. The costs are relative, not absolute. A sequential page read costs one unit; a random page read costs four; a CPU comparison costs a fraction of a unit.

The cost units

PostgreSQL’s cost units are calibrated against the time to read a sequential page on the machine where PostgreSQL was compiled. The defaults are documented in the postgresql.conf file as seq_page_cost, random_page_cost, cpu_tuple_cost, cpu_index_tuple_cost, and cpu_operator_cost.

ParameterDefaultMeaning
seq_page_cost1.0Sequential page read
random_page_cost4.0Random page read
cpu_tuple_cost0.01Processing one row
cpu_index_tuple_cost0.005Processing one index entry
cpu_operator_cost0.0025Evaluating one operator

The ratio between seq_page_cost and random_page_cost is what makes the optimizer prefer sequential scans when many rows are needed and index scans when few are needed. On SSDs, the ratio is often changed to 1.1 or 2.0 because random reads are nearly as fast as sequential.

The selectivity estimate

The optimizer estimates the selectivity of each filter, which is the fraction of rows the filter will match. The estimate is based on the column’s statistics.

SELECT * FROM orders WHERE status = 'pending';

If the statistics show that status has four distinct values and pending is 25% of the rows, the optimizer estimates the filter’s selectivity at 0.25. The estimated rows are the table’s row count multiplied by the selectivity.

The histograms

For columns with many distinct values, the optimizer uses histograms to estimate selectivity for range filters. The histogram divides the value range into buckets of roughly equal size, and the optimizer interpolates within a bucket.

SELECT * FROM orders WHERE order_date > '2026-01-01';

The histogram tells the optimizer what fraction of rows fall in each date range. The estimate is the sum of the fractions for the buckets above the threshold.

The distinct value count

For equality filters, the optimizer uses the number of distinct values. If a column has N distinct values and the data is uniformly distributed, the selectivity of an equality filter is approximately 1/N. When the data is skewed, the estimate is wrong.

SELECT * FROM orders WHERE customer_id = 42;

If customer_id has 50,000 distinct values and one million rows, the estimate is 20 rows. If some customers have 10,000 orders and most have 5, the estimate is right on average and wrong for the specific value.

The correlation statistic

The optimizer tracks the correlation between a column’s value and its physical position. A correlation of 1.0 means the column is perfectly ordered; 0.0 means it is random. High correlation means an index scan on that column is cheap, because the rows are physically adjacent. Low correlation means the index scan is expensive, because each row is on a different page.

SELECT correlation FROM pg_stats
WHERE tablename = 'orders' AND attname = 'order_date';

The order_date column on an append-only table usually has correlation near 1.0, which makes an index scan on it efficient. The customer_id column usually has low correlation, which makes index scans expensive and sequential scans preferable for large result sets.


c. Common plan problems

The optimizer produces a good plan most of the time. When it does not, the pattern is usually one of the following.

The sequential scan on a selective filter

A filter that matches few rows should use an index. If the plan shows a Seq Scan with Rows Removed by Filter much larger than the actual rows, the index is missing or the estimate is wrong.

Seq Scan on orders  (cost=0.00..45000.00 rows=1 width=50)
                   (actual time=0.015..850.000 rows=1 loops=1)
  Filter: (customer_id = 42)
  Rows Removed by Filter: 999999

The estimate (rows=1) is correct, but the optimizer still chose a sequential scan. This usually means the index does not exist, or the index is not usable for this query, or the cost model incorrectly prefers the scan.

The index scan on a non-selective filter

An index scan on a filter that matches many rows is worse than a sequential scan, because each row is on a different page. If the plan shows an Index Scan returning a large fraction of the table, the optimizer may have overestimated the benefit.

Index Scan using idx_orders_status on orders  (cost=... rows=250000)
                                              (actual time=... rows=250000)
  Index Cond: (status = 'pending')

A quarter of the table is being fetched through an index, which means 250,000 random page reads. The sequential scan would have been faster.

The nested loop with a large outer input

A nested loop join is efficient when the outer input is small and the inner is indexed. It is disastrous when the outer input is large, because the inner is scanned once per outer row.

Nested Loop  (cost=... rows=...)
  ->  Seq Scan on orders  (actual rows=100000)
  ->  Index Scan on customers  (actual rows=1 loops=100000)

The loops=100000 means the index scan ran 100,000 times, once for each order. The total cost is 100,000 times the cost of one lookup. A hash join would have been better.

The sort spill to disk

A sort that does not fit in memory spills to disk, which is much slower than an in-memory sort. The plan shows the spill with Sort Method: external merge Disk: N kB.

Sort  (cost=... rows=...)
  Sort Key: created_at DESC
  Sort Method: external merge  Disk: 24576 kB

The fix is to raise work_mem for the session or the query, or to add an index that provides the sort order.

The misestimate from a stale statistic

A statistic that has not been updated after a large data change produces wrong estimates, which produce wrong plans. The plan shows the estimate far from the actual.

Seq Scan on orders  (cost=... rows=1000 width=50)
                   (actual time=... rows=500000 loops=1)

The estimate is 1,000 and the actual is 500,000. The optimizer chose a sequential scan because it thought the result was small, but the result is large. The fix is to run ANALYZE orders to refresh the statistics.


Complete Example Session

-- ============================================
-- PART 1: A SIMPLE QUERY
-- ============================================

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;

-- Seq Scan on orders  (cost=0.00..45000.00 rows=20 width=50)
--                    (actual time=0.015..850.000 rows=20 loops=1)
--   Filter: (customer_id = 42)
--   Rows Removed by Filter: 999980
-- Planning Time: 0.234 ms
-- Execution Time: 852.123 ms

-- 1,000,000 rows read to return 20.
-- The optimizer estimated 20 rows correctly.
-- No index exists on customer_id.

-- ============================================
-- PART 2: ADD THE INDEX
-- ============================================

CREATE INDEX idx_orders_customer ON orders(customer_id);

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;

-- Index Scan using idx_orders_customer on orders
--   (cost=0.43..8.45 rows=20 width=50)
--   (actual time=0.020..0.035 rows=20 loops=1)
--   Index Cond: (customer_id = 42)
-- Planning Time: 0.312 ms
-- Execution Time: 0.052 ms

-- 0.052 ms instead of 852 ms.

-- ============================================
-- PART 3: THE MISESTIMATE
-- ============================================

EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 'pending';

-- Seq Scan on orders  (cost=0.00..45000.00 rows=250000 width=50)
--                    (actual time=0.015..620.000 rows=250000 loops=1)
--   Filter: (status = 'pending'::text)
--   Rows Removed by Filter: 750000
-- Planning Time: 0.198 ms
-- Execution Time: 622.345 ms

-- The estimate matches the actual.
-- The optimizer chose a sequential scan because 25% of rows match.
-- This is the correct plan.

-- ============================================
-- PART 4: THE BAD JOIN
-- ============================================

EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.order_date > '2026-01-01';

-- Nested Loop  (cost=... rows=...)
--              (actual time=... rows=... loops=1)
--   ->  Seq Scan on orders o  (actual rows=100000)
--   ->  Index Scan on customers c  (actual rows=1 loops=100000)
--         Index Cond: (c.id = o.customer_id)

-- 100,000 index lookups for 100,000 orders.
-- A hash join would be faster.

-- ============================================
-- PART 5: THE FIX — UPDATE STATISTICS
-- ============================================

ANALYZE orders;

EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.order_date > '2026-01-01';

-- Hash Join  (cost=... rows=...)
--            (actual time=... rows=... loops=1)
--   Hash Cond: (o.customer_id = c.id)
--   ->  Seq Scan on orders o  (actual rows=100000)
--   ->  Hash  (actual rows=50000)
--         ->  Seq Scan on customers c  (actual rows=50000)

-- The hash join builds the customers hash table once and probes it.
-- The 100,000 lookups are replaced by a single hash table build.

-- ============================================
-- PART 6: THE SORT SPILL
-- ============================================

EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;

-- Limit  (cost=... rows=20 width=...)
--   ->  Sort  (cost=... rows=1000000 width=...)
--         Sort Key: created_at DESC
--         Sort Method: external merge  Disk: 24576 kB
--         ->  Seq Scan on orders  (actual rows=1000000)

-- The sort spilled to disk.
-- Fix: add an index on created_at DESC.

CREATE INDEX idx_orders_created_desc ON orders(created_at DESC);

EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;

-- Limit  (cost=... rows=20 width=...)
--   ->  Index Scan using idx_orders_created_desc on orders
--         (actual rows=20 loops=1)

-- The index provides the sort order.
-- No sort, no spill.

-- ============================================
-- PART 7: THE BITMAP SCAN
-- ============================================

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id IN (42, 99, 137);

-- Bitmap Heap Scan on orders  (cost=... rows=...)
--   Recheck Cond: (customer_id = ANY ('{42,99,137}'))
--   Heap Blocks: exact=60
--   ->  Bitmap Index Scan on idx_orders_customer
--         Index Cond: (customer_id = ANY ('{42,99,137}'))

-- Three index scans merged into one bitmap.
-- The heap is read in physical page order.

-- ============================================
-- PART 8: THE INDEX-ONLY SCAN
-- ============================================

EXPLAIN ANALYZE
SELECT customer_id, order_date FROM orders WHERE customer_id = 42;

-- Index Only Scan using idx_orders_customer_date on orders
--   Index Cond: (customer_id = 42)
--   Heap Fetches: 0

-- The index contains both columns.
-- The heap is never touched.

-- ============================================
-- PART 9: THE COST PARAMETERS
-- ============================================

-- Check the current cost settings.
SHOW seq_page_cost;
SHOW random_page_cost;

-- On SSDs, lower random_page_cost.
SET random_page_cost = 1.1;

-- The optimizer now prefers index scans more often.

-- ============================================
-- PART 10: THE PLAN REGRESSION
-- ============================================

-- Before the data change: the plan was fast.
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'archived';
-- Index Scan using idx_orders_status on orders
--   (actual time=0.5..2.3 rows=100 loops=1)

-- After the data change: the plan is slow.
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'archived';
-- Seq Scan on orders
--   (actual time=0.015..620.000 rows=500000 loops=1)
--   Filter: (status = 'archived')
--   Rows Removed by Filter: 500000

-- The statistics were stale.
ANALYZE orders;

-- The plan corrects itself.

The ten parts showed a simple query, the effect of adding an index, a correct sequential scan, a bad join, the fix by updating statistics, a sort spill, a bitmap scan, an index-only scan, the cost parameters, and a plan regression.


Quick Reference

EXPLAIN Commands

CommandPurpose
EXPLAINShow the plan without executing
EXPLAIN ANALYZEExecute and show actual times
EXPLAIN (ANALYZE, BUFFERS)Include buffer usage
EXPLAIN (FORMAT JSON)Machine-readable output
EXPLAIN (VERBOSE)Include output columns

Plan Operators

OperatorWhen Used
Seq ScanNo usable index, or large result
Index ScanSelective filter, low correlation
Index Only ScanAll columns in the index
Bitmap Heap ScanModerate selectivity, many rows
Nested LoopSmall outer, indexed inner
Hash JoinLarge inputs, no useful order
Merge JoinBoth inputs sorted
SortNo index provides the order

Cost Parameters

ParameterDefaultEffect
seq_page_cost1.0Sequential read
random_page_cost4.0Random read
cpu_tuple_cost0.01Row processing
work_mem4 MBSort and hash memory

Common Fixes

ProblemFix
Seq Scan on selective filterAdd an index
MisestimateANALYZE the table
Bad join orderUpdate statistics or rewrite
Sort spillAdd an index or raise work_mem
Index scan on large resultForce sequential scan or drop the index

Best Practices

✅ Do This:

-- Always run EXPLAIN ANALYZE before tuning.
EXPLAIN ANALYZE SELECT ...;  -- ✅

-- Compare estimated rows to actual rows.
-- Large discrepancies indicate bad statistics.  -- ✅

-- Update statistics after large data changes.
ANALYZE orders;  -- ✅

-- Add indexes for selective filters.
CREATE INDEX idx ON orders(customer_id);  -- ✅

-- Add indexes that provide sort order.
CREATE INDEX idx ON orders(created_at DESC);  -- ✅

-- Raise work_mem for complex sorts and hashes.
SET work_mem = '256MB';  -- ✅

-- Tune random_page_cost on SSDs.
SET random_page_cost = 1.1;  -- ✅

❌ Don’t Do This:

-- Don't tune without reading the plan.
SELECT ... ; -- and guessing  -- ❌

-- Don't ignore the estimated vs actual difference.
-- It is the primary diagnostic.  -- ❌

-- Don't add indexes for every filter.
-- Each index taxes writes.  -- ❌

-- Don't forget to ANALYZE after bulk loads.
COPY orders FROM ...;  -- ❌ Statistics are stale

-- Don't assume the optimizer is wrong.
-- The estimates are usually correct.  -- ❌

-- Don't force plans with hints without understanding.
-- Fix the statistic or the query.  -- ❌

-- Don't ignore the cost parameters.
-- The defaults assume spinning disks.  -- ❌

Common Pitfalls

PitfallWhy It HappensFix
Slow query with indexStale statisticsANALYZE
Seq Scan on selective filterMissing indexAdd the index
Index scan on large resultOverestimated selectivityCheck statistics
Nested loop with large outerMisestimated join sizeUpdate statistics
Sort spillLow work_memRaise it or add an index
Plan changes after data loadStatistics not updatedANALYZE after the load
Cost parameters wrong for SSDDefaults assume HDDLower random_page_cost
Bitmap scan slower than expectedToo many matching pagesCheck selectivity

Real-World Examples

1. Basic Plan

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

2. Plan with Actual Times

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

3. Plan with Buffers

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders;

4. Update Statistics

ANALYZE orders;

5. Add an Index

CREATE INDEX idx_orders_customer ON orders(customer_id);

6. Add a Sort Index

CREATE INDEX idx_orders_created ON orders(created_at DESC);

7. Raise work_mem

SET work_mem = '256MB';

8. Check Correlation

SELECT correlation FROM pg_stats WHERE tablename = 'orders';

9. Tune for SSD

SET random_page_cost = 1.1;

10. Detect a Regression

-- Compare plans before and after a data change.
-- The estimated rows diverge from the actual.

Visual

The Plan Tree

┌────────────────────────────────────────────────────────────┐
│  EXECUTION PLAN TREE                                       │
│                                                            │
│  Hash Join  ← root, produces the result                    │
│    │                                                       │
│    ├── Seq Scan on orders                                  │
│    │     ← leaf, reads the orders table                    │
│    │                                                       │
│    └── Hash                                                │
│          │                                                 │
│          └── Seq Scan on customers                         │
│                ← leaf, reads the customers table           │
│                                                            │
│  Execution flows from the leaves to the root.              │
│  The Hash builds a table from customers.                   │
│  The Hash Join probes it for each order.                   │
└────────────────────────────────────────────────────────────┘

Estimate vs Actual

┌────────────────────────────────────────────────────────────┐
│  THE KEY DIAGNOSTIC                                        │
│                                                            │
│  Seq Scan on orders  (cost=0.00..45000.00 rows=20 width=50)│
│                     (actual time=0.015..850.000 rows=20)   │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  rows=20          ← the estimate                  │     │
│  │  actual rows=20   ← the reality                   │     │
│  │                                                   │     │
│  │  They match. The plan is correctly chosen.        │     │
│  │  The problem is elsewhere: no index exists.       │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  ──────────────────────────────────────────────────────    │
│                                                            │
│  Seq Scan on orders  (cost=... rows=1000 width=50)         │
│                     (actual time=... rows=500000)          │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  rows=1000        ← the estimate (wrong)          │     │
│  │  actual rows=500000 ← the reality                 │     │
│  │                                                   │     │
│  │  The estimate is off by 500×.                      │     │
│  │  The statistics are stale. Run ANALYZE.            │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

Join Strategies

┌────────────────────────────────────────────────────────────┐
│  THREE JOIN STRATEGIES                                     │
│                                                            │
│  NESTED LOOP                                               │
│  ┌──────────────────────────────────────────────────┐     │
│  │  For each row of the outer:                       │     │
│  │    Look up matching rows in the inner.            │     │
│  │                                                   │     │
│  │  Best when: outer is small, inner is indexed.     │     │
│  │  Worst when: outer is large.                      │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  HASH JOIN                                                 │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Build a hash table from one input.               │     │
│  │  Probe it for each row of the other.              │     │
│  │                                                   │     │
│  │  Best when: both inputs are large, no order.      │     │
│  │  Worst when: the hash table spills to disk.       │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  MERGE JOIN                                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Sort both inputs.                                │     │
│  │  Walk them in parallel.                           │     │
│  │                                                   │     │
│  │  Best when: both inputs are already sorted.       │     │
│  │  Worst when: a sort is needed for both.           │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Cost Model

┌────────────────────────────────────────────────────────────┐
│  COST MODEL                                                │
│                                                            │
│  seq_page_cost    = 1.0   ← sequential read                │
│  random_page_cost = 4.0   ← random read                    │
│  cpu_tuple_cost   = 0.01  ← row processing                 │
│                                                            │
│  SEQUENTIAL SCAN of N pages:                               │
│    cost = N × 1.0                                          │
│                                                            │
│  INDEX SCAN for M rows on N pages:                         │
│    cost = M × (tree traversal) + M × 4.0                   │
│                                                            │
│  The optimizer picks the cheaper option.                   │
│  On SSDs, random reads are nearly as fast as sequential,   │
│  so random_page_cost should be lowered.                    │
│                                                            │
│  The ratio determines the crossover point.                 │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
Execution planThe database’s chosen strategy
EXPLAINShow the plan without executing
EXPLAIN ANALYZEExecute and show actual times
Estimated rowsThe optimizer’s prediction
Actual rowsThe real row count
Large discrepancyIndicates stale statistics
ANALYZEUpdate statistics
Seq ScanRead every row
Index ScanWalk an index
Bitmap Heap ScanBatch heap reads
Nested LoopSmall outer, indexed inner
Hash JoinLarge inputs
Merge JoinSorted inputs
work_memSort and hash memory

Key takeaways:

  • The execution plan is the only reliable way to diagnose a slow query. Reading the plan shows which operators are used, how many rows flow through each, and where the time is spent. Without the plan, tuning is guesswork.
  • The estimate-versus-actual comparison is the primary diagnostic. When the estimated rows diverge from the actual rows by orders of magnitude, the optimizer’s statistics are misleading, and the plan is likely wrong.
  • Statistics are the foundation of the optimizer’s decisions. Row counts, distinct value counts, histograms, and correlation determine the estimated selectivity of every filter. Stale statistics produce wrong estimates and wrong plans.
  • ANALYZE refreshes the statistics. After a bulk load, a large delete, or a significant data change, ANALYZE brings the statistics up to date. Automatic analyze runs on a schedule, but a manual run after a bulk load is often necessary.
  • Each join strategy has a purpose. Nested loops are efficient for small outer inputs with indexed inner tables. Hash joins are efficient for large inputs with no useful order. Merge joins are efficient when both inputs are already sorted.
  • Sort operations can spill to disk. When the sort does not fit in work_mem, it writes to a temporary file, which is much slower. Raising work_mem for the session or adding an index that provides the sort order avoids the spill.
  • The cost parameters assume spinning disks. The default random_page_cost of 4.0 reflects the cost of a seek on a mechanical disk. On SSDs, random reads are nearly as fast as sequential, and lowering the parameter to 1.1 or 2.0 makes the optimizer prefer index scans where appropriate.
  • Plan regressions happen without code changes. A query that was fast last week can be slow this week because the data distribution changed and the statistics were not updated. Comparing plans before and after the change reveals the cause.

Remember: Execution plans are the window into how the database satisfies a query. The optimizer chooses a plan based on statistics, and the statistics are approximations. Reading the plan means comparing the estimated rows to the actual rows, identifying the operators, and understanding the cost model. The most common fix is to update the statistics with ANALYZE, the second most common is to add an index that the query can use, and the third is to rewrite the query so the optimizer’s estimates match reality. Cost parameters, especially random_page_cost on SSDs, can be tuned to match the hardware. The result is a query that runs in milliseconds instead of minutes, and a developer who understands why.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!