Everything you've read in the module so far is rules, and in performance rules get things wrong. Does that query really use the index? Does IN (SELECT ...) really become a semi-join, as 07-05 promised? Is the problem really the statistics? There's only one honest way to answer, and it's the tool that turns this whole module into a method: EXPLAIN. Here you'll learn to read an execution plan node by node, to tell the estimated plan from the actual one, to recognise the signal that gives stale statistics away, and to interpret the fifteen nodes that appear in 95 % of plans. And you'll perform the module's central demonstration: you'll build a two-million-row test table —because with GreenStore's 20 products there's nothing to demonstrate— and you'll see with your own eyes the difference between walking two million rows and jumping straight to ten.

Contents

  1. EXPLAIN and EXPLAIN ANALYZE
  2. How to read a plan
  3. cost, rows, width, actual time and loops
  4. The most useful signal: estimated against actual
  5. The nodes you'll see most
  6. Useful EXPLAIN options
  7. The test bench: two million rows
  8. The demonstration: without an index and with one
  9. And in the real GreenStore: why the index is ignored
  10. Verifying 07-05's promise
  11. Measuring in other engines and visualisation tools
  12. Maintenance: VACUUM, ANALYZE and bloat
  13. A six-step method
  14. Common Mistakes and Tips
  15. Exercises
  16. Module conclusion

  1. EXPLAIN and EXPLAIN ANALYZE

EXPLAIN SELECT ...;            -- the plan the engine INTENDS to run: estimates only
EXPLAIN ANALYZE SELECT ...;    -- RUNS the query: estimates AND real measurements

The first costs microseconds and tells you which path it would choose; the second costs whatever the query costs and tells you what actually happened.

⚠️ The indispensable warning. EXPLAIN ANALYZE actually runs the query. With a SELECT that's harmless; with an UPDATE, a DELETE or an INSERT, it modifies the data. The safe way to analyse those is to wrap them in a transaction that gets undone:

BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE order_date < '2025-01-01';
ROLLBACK;

The plan is displayed, the rows are deleted… and the ROLLBACK undoes it all. BEGIN, COMMIT and ROLLBACK are module 9; here it's enough to use this pattern as a mandatory precaution. It's the same safety net you already saw in 05-04 before a DELETE with no WHERE.

  1. How to read a plan

A plan is a tree of nodes: each one receives rows from its children, does something with them and passes them on to its parent. The text output represents it with indentation and -> arrows, and it's read in a specific way:

From the inside outwards and from the bottom upwards. The most indented node runs first; the one on the first line, last, and it's the one producing the final result.

flowchart BT
    A["<b>Seq Scan</b> on customers<br/><i>reads 15 rows</i>"] --> C["<b>Hash Join</b><br/><i>matches by customer_id</i>"]
    B["<b>Seq Scan</b> on orders<br/><i>reads 20 rows</i>"] --> H["<b>Hash</b><br/><i>hash table of orders</i>"]
    H --> C
    C --> S["<b>Sort</b><br/><i>sorts by date</i>"]
    S --> L(["<b>Limit</b><br/><i>returns 10: the result</i>"])

In text, that same tree is printed the other way up: Limit at the very top, the two Seq Scans at the bottom. The two questions the tree's shape answers are always the same: where does the engine get into the data? (the leaf nodes) and how does it combine them? (the join nodes).

  1. cost, rows, width, actual time and loops

Every line of a plan carries its numbers. With a plain EXPLAIN there are only estimates; with ANALYZE the second half in parentheses is added:

Seq Scan on products  (cost=0.00..1.25 rows=7 width=45) (actual time=0.012..0.016 rows=7 loops=1)
Field What it means
cost=0.00..1.25 Startup cost .. total cost, in the planner's arbitrary units (1.0 = reading one page sequentially). The startup cost is what it takes before emitting the first row
rows=7 (estimated) Rows the planner estimates will come out of this node
width=45 Estimated average width of each row, in bytes
actual time=0.012..0.016 Milliseconds to the first row .. to the last. Per execution
rows=7 (actual) Rows that really came out, on average per execution
loops=1 How many times this node was executed

The startup cost distinguishes two families of node: the ones that emit rows as they read them (Seq Scan, Index Scan, Nested Loop) have a startup cost of ≈ 0; the ones that need all the rows before returning the first (Sort, Hash, HashAggregate) have a high one. That matters a great deal with LIMIT.

The loops trap. actual time and rows are averages per execution, not totals. A node with actual time=0.05..0.08 rows=3 loops=20000 didn't take 0.08 ms: it took 0.08 × 20,000 = 1.6 seconds, and produced 60,000 rows. Always multiply by loops before deciding where the problem is.

At the end of the plan you get Planning Time (how long it took to decide the plan) and Execution Time (how long it took to run it). If they're comparable, you have a trivial query executed a great many times: the case of prepared statements and, often, of 08-04's N+1.

  1. The most useful signal: estimated against actual

If you take only one thing away from this lesson, let it be this: compare the estimated rows= with the actual rows= on every node. A big divergence —an order of magnitude or more— is the root cause of almost every bad plan.

->  Seq Scan on orders  (cost=0.00..41250.00 rows=42 width=25)
                        (actual time=0.03..1893.44 rows=284561 loops=1)

The planner expected 42 rows and 284,561 came out. With 42 rows, chaining a Nested Loop is the perfect decision; with 284,561, it's a catastrophe. The plan isn't badly chosen: it's well chosen for a reality that doesn't exist.

The four causes, in order of frequency: stale statistics after a load or after growth, fixed with ANALYZE table;; correlated columns the planner assumes are independent, with CREATE STATISTICS; a predicate it can't estimate (a complex expression, a custom function), which has to be rewritten or indexed as an expression; and a sample too small for a very uneven distribution, with ALTER TABLE ... SET STATISTICS. All four are developed in 08-04. When estimated and actual are similar, the plan is usually the best available: if it's still slow, the problem is one of indexes or volume, not of the planner.

  1. The nodes you'll see most

Node What it does What seeing it usually means
Seq Scan Reads the whole table block by block Normal on small tables or poorly selective filters; suspicious on large tables with a selective filter
Index Scan Walks the index and goes to the table for each row The good case when few rows are returned
Index Only Scan Resolves everything inside the index The best case. Watch the Heap Fetches: if it's high, VACUUM is due
Bitmap Index Scan + Bitmap Heap Scan The first builds a bitmap of the blocks with matches; the second reads them in physical order, once each They always go together. The planner prefers them when it expects many scattered rows: it turns random accesses into almost sequential ones
Nested Loop For each row on the left, it looks up the right Excellent if the left has few rows; catastrophic if it has millions. Look at its loops
Hash Join Builds a hash table with the small table and walks the large one The workhorse of large equality JOINs
Merge Join Walks both already-sorted inputs in parallel It appears when both arrive sorted (by index or by a Sort)
Sort / Incremental Sort Sorts rows; the second takes advantage of them already arriving partly sorted If Sort Method: external merge Disk appears, it has spilled to disk: an index or work_mem is missing. Seeing Incremental Sort is a good sign: the index covers part of the ORDER BY
HashAggregate / GroupAggregate They group, the first with a hash table and the second over already-sorted rows The usual GROUP BY is the hash one, and it needs memory; the second appears with many groups or when the rows already arrived sorted
Limit Cuts and stops execution Well placed, it makes the rest of the plan stop early
Materialize Keeps a result in memory to reread it Typical inside a Nested Loop, so as not to recompute the right-hand side
Gather / Parallel ... Spreads the work across processes and collects the results Parallelism. Workers Launched can be lower than Workers Planned

Two auxiliary lines always worth looking at: Filter: with its Rows Removed by Filter: (rows read and thrown away — if they're in the millions, an index is missing or the filter should have come earlier) and Index Cond: (the part of the condition the index did resolve; whatever's left in Filter is what had to be checked row by row).

  1. Useful EXPLAIN options

They go in parentheses, separated by commas: EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...;.

Option What it adds When to use it
ANALYZE Runs and measures Whenever you can
BUFFERS Blocks read: shared hit (cache), read (disk), dirtied, written Almost always: see below
VERBOSE Output columns, schemas, full names Queries with many aliases
SETTINGS Configuration parameters changed from their default value When the same SQL gives different plans on two servers
TIMING OFF / WAL Measures without timing each node / transaction-log activity generated When the timing distorts things / when analysing writes
FORMAT JSON Structured output For tools and visualisers

Why BUFFERS matters so much: the time depends on whether the machine is busy, on whether the data was in cache and on what else is running at the same time. The blocks read don't. A plan that reads 16,250 blocks will read 16,250 blocks today, tomorrow and on your colleague's laptop. On a shared machine —an integration server, a container, the cloud— the blocks are the only reproducible metric you have.

  1. The test bench: two million rows

Here we have to be honest: nothing of this can be demonstrated with GreenStore. Twenty products and forty-seven lines fit in one disk page, and the planner will do a Seq Scan every time and be entirely right. Showing you an invented plan over those tables would be lying to you. So we're going to build a big, reproducible table with generate_series.

⚠️ MODULE 8 TEST BENCH. The orders_large table isn't part of GreenStore's canonical schema (01-06). It has no foreign keys, it isn't related to any other table and no later module uses it. When you finish the lesson, drop it: DROP TABLE orders_large;.

-- Module 8 test bench. NOT part of GreenStore.
-- It takes up around 130 MB and takes between 10 and 60 seconds to generate.
DROP TABLE IF EXISTS orders_large;

CREATE TABLE orders_large (
    id          INTEGER       PRIMARY KEY,
    customer_id INTEGER       NOT NULL,
    order_date  DATE          NOT NULL,
    status      VARCHAR(20)   NOT NULL,
    amount      NUMERIC(10,2) NOT NULL
);

INSERT INTO orders_large (id, customer_id, order_date, status, amount)
SELECT g,
       (g % 200000) + 1,                                  -- 200,000 customers, 10 orders each
       DATE '2019-01-01' + (g / 782),                     -- 782 orders a day, in chronological order
       (ARRAY['pending','paid','shipped','delivered','cancelled'])[(g % 5) + 1],
       ROUND((random() * 395 + 5)::numeric, 2)
FROM generate_series(1, 2000000) AS g;

-- Indispensable: with no statistics, the planner is flying blind (08-04)
ANALYZE orders_large;
SELECT pg_size_pretty(pg_relation_size('orders_large')) AS table_size,
       pg_relation_size('orders_large') / 8192          AS pages,
       COUNT(*)                                         AS rows
FROM orders_large;
table_size pages rows
127 MB 16250 2000000

(A sample run. The exact size depends on the version and on type alignment; the order of magnitude doesn't.) Against products' single page, here there are sixteen thousand two hundred and fifty. Now there really is something to optimize.

  1. The demonstration: without an index and with one

Let's start with no index: customer_id has none — only id does, from its primary key.

EXPLAIN (ANALYZE, BUFFERS) SELECT id, order_date, status, amount
FROM orders_large WHERE customer_id = 12345;
Gather  (cost=1000.00..14750.30 rows=10 width=25) (actual time=0.412..142.118 rows=10 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  Buffers: shared hit=16250
  ->  Parallel Seq Scan on orders_large  (cost=0.00..13749.30 rows=4 width=25)
                                         (actual time=95.204..131.502 rows=3 loops=3)
        Filter: (customer_id = 12345)
        Rows Removed by Filter: 666663
Planning Time: 0.096 ms
Execution Time: 142.180 ms

(A sample run: the milliseconds and the costs depend on your machine. What has to be read are the proportions and the node types.) Three things this plan shouts: Parallel Seq Scan, because there's no index and the whole table has to be walked —it's so big that PostgreSQL spreads the work across three processes and collects it with Gather—; Rows Removed by Filter: 666663 per worker, that is, two million rows read and thrown away to keep ten; and Buffers: shared hit=16250, the table's 16,250 pages, every one of them.

Now the index, and the same query:

CREATE INDEX idx_orders_large_customer ON orders_large (customer_id);
EXPLAIN (ANALYZE, BUFFERS) SELECT id, order_date, status, amount
FROM orders_large WHERE customer_id = 12345;
Index Scan using idx_orders_large_customer on orders_large
        (cost=0.43..39.05 rows=10 width=25) (actual time=0.038..0.061 rows=10 loops=1)
  Index Cond: (customer_id = 12345)
  Buffers: shared hit=13
Planning Time: 0.121 ms
Execution Time: 0.086 ms

The comparison, which is the whole module's reason for existing:

Without an index With an index Factor
Node Parallel Seq Scan + Gather (3 processes) Index Scan (1 process)
Estimated cost ~14,750 ~39 ~380×
Blocks read 16,250 13 1,250×
Rows discarded 2,000,000 0
Execution time ~142 ms ~0.09 ms ~1,600×

And notice Index Cond against Filter: in the second plan there's no Filter. The index didn't filter after reading: it positioned itself directly on the ten rows. That's the difference between searching and discarding.

The third plan: Bitmap Heap Scan

With a filter that returns a fair number of scattered rows, the planner chooses a middle route:

EXPLAIN (ANALYZE, BUFFERS) SELECT id, amount FROM orders_large WHERE customer_id BETWEEN 1000 AND 1200;
Bitmap Heap Scan on orders_large  (cost=45.06..7527.19 rows=2010 width=12)
                                  (actual time=1.204..18.442 rows=2010 loops=1)
  Recheck Cond: ((customer_id >= 1000) AND (customer_id <= 1200))
  Heap Blocks: exact=1988
  Buffers: shared hit=1994
  ->  Bitmap Index Scan on idx_orders_large_customer  (cost=0.00..44.56 rows=2010 width=0)
                                                      (actual time=0.612..0.612 rows=2010 loops=1)
        Index Cond: ((customer_id >= 1000) AND (customer_id <= 1200))

It reads from the bottom up: the Bitmap Index Scan walks the index and builds a bitmap of the blocks containing matches; the Bitmap Heap Scan reads them in physical order, once each. With 2,010 rows spread across almost 2,000 different blocks, an Index Scan would make 2,010 random jumps; the bitmap turns them into an almost sequential walk of 1,988 blocks. It's the planner's answer to "many rows, but not the whole table".

  1. And in the real GreenStore: why the index is ignored

Come back to the world of 20 rows and create the catalogue's most reasonable index, on products.price:

CREATE INDEX idx_products_price ON products (price);
EXPLAIN ANALYZE SELECT p.id, p.name, p.price FROM products AS p WHERE p.price > 10;
Seq Scan on products p  (cost=0.00..1.25 rows=7 width=45) (actual time=0.012..0.016 rows=7 loops=1)
  Filter: (price > 10::numeric)
  Rows Removed by Filter: 13
Planning Time: 0.184 ms
Execution Time: 0.031 ms

Seq Scan, with the index freshly created and unused. And the planner is entirely right. Look at its cost: 1.25. It breaks down like this: 1 page × 1.0 (reading the table's only block) + 20 rows × 0.01 (processing each one) + 20 × 0.0025 (evaluating the filter) = 1.25. There's nothing cheaper than that, because the whole table is a single access.

We can force it to use the index to see what would have happened:

SET enable_seqscan = off;   -- ONLY for diagnosis, never in production
EXPLAIN ANALYZE SELECT p.id, p.name, p.price FROM products AS p WHERE p.price > 10;
SET enable_seqscan = on;
Index Scan using idx_products_price on products p  (cost=0.14..12.35 rows=7 width=45)
                                                   (actual time=0.031..0.041 rows=7 loops=1)
  Index Cond: (price > 10::numeric)
Planning Time: 0.211 ms
Execution Time: 0.062 ms

A cost of 12.35 against 1.25: ten times more expensive. And the real time, double. The reason is exactly 08-01's: using the index forces the metadata page to be read, the tree to be descended, seven ctids to be obtained and the same table page to be read anyway that the Seq Scan would have read in one go. Three or four accesses to do the work of one. Three conclusions, which close the module's first lesson: an index over a table that fits in one page never pays off, and the planner knows it; enable_seqscan = off is a diagnostic tool, not a solution, and it serves to answer "what would it have done with the index?" and nothing else; and the indexes you created in 08-02 don't speed anything up today — they're correct, they're well designed and they'd be decisive at real volume, but with 20 rows there's no problem to solve.

  1. Verifying 07-05's promise

07-05 claimed that PostgreSQL turns IN (SELECT ...) into a semi-join, and that this is why its performance is equivalent to a JOIN's. Let's check:

EXPLAIN ANALYZE
SELECT c.id, c.name FROM customers AS c WHERE c.id IN (SELECT customer_id FROM orders);
Hash Semi Join  (cost=1.45..2.71 rows=12 width=10) (actual time=0.048..0.062 rows=12 loops=1)
  Hash Cond: (c.id = orders.customer_id)
  ->  Seq Scan on customers c  (cost=0.00..1.15 rows=15 width=10) (actual time=0.008..0.010 rows=15 loops=1)
  ->  Hash  (cost=1.20..1.20 rows=20 width=4) (actual time=0.021..0.021 rows=20 loops=1)
        ->  Seq Scan on orders  (cost=0.00..1.20 rows=20 width=4) (actual time=0.006..0.010 rows=20 loops=1)
Planning Time: 0.352 ms
Execution Time: 0.104 ms

Hash Semi Join. You wrote a subquery and the engine ran a join — a special join that stops at the first match of each left-hand row, which is why it returns 12 customers and not 20 rows. The promise was true, and now you don't believe it because you read it: you've seen it. Try swapping IN for NOT IN and you'll see the Semi Join disappear and a filter with a separately executed subquery appear: the visual confirmation of why 07-05 advised against NOT IN.

  1. Measuring in other engines and visualisation tools

Engine Estimated plan Actual plan / I-O
PostgreSQL 16 EXPLAIN EXPLAIN (ANALYZE, BUFFERS)
MySQL 8 EXPLAIN, EXPLAIN FORMAT=JSON (with costs) EXPLAIN ANALYZE (since 8.0.18)
SQLite EXPLAIN QUERY PLAN (very abridged) No equivalent; you measure with .timer on
SQL Server Estimated Execution Plan (SET SHOWPLAN_XML ON) Actual Execution Plan + SET STATISTICS IO, TIME ON
Oracle EXPLAIN PLAN FOR ... + DBMS_XPLAN.DISPLAY DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST')

Dialect note: the concepts travel (sequential scan, index access, join types, estimated against actual), but the vocabulary and the units don't. PostgreSQL's cost isn't comparable with MySQL's or with Oracle's. And there are fundamental differences: SQL Server and MySQL/InnoDB use clustered indexes, where the table is the primary index, so their equivalent of the Index Scan doesn't need the second access 08-01 talked about. SQL Server's SET STATISTICS IO ON is BUFFERS' close relative, and for the same reason: it counts logical reads, which are reproducible.

And four tools that make a hundred-line plan readable: explain.dalibo.com, where you paste the plan (ideally in FORMAT JSON) and it draws it as a tree, highlighting the most expensive node and the wrong estimates; pev2, the same visualiser, embeddable in your own tools; auto_explain, a server module that automatically logs the plan of any query exceeding a time threshold —indispensable for what only fails in production—; and pg_stat_statements, the ranking of which queries to analyse (08-03).

  1. Maintenance: VACUUM, ANALYZE and bloat

There's a part of performance that doesn't depend on your queries. In PostgreSQL, an UPDATE doesn't modify the row: it writes a new version and marks the old one as dead. A DELETE doesn't delete either: it marks. The dead versions pile up and fatten tables and indexes without contributing anything: that's bloat. Its effects are measurable: a table with 60 % dead space takes up three times the necessary pages, so every Seq Scan reads three times as much, the cache performs at a third and Index Only Scans lose their effectiveness (the Heap Fetches go up).

Command What it does Locking
VACUUM table; Marks the dead space as reusable None that prevents working
ANALYZE table; Updates the planner's statistics None
VACUUM FULL table; Rewrites the table and returns the space to the system Exclusive lock: nobody can read or write
-- A quick diagnosis of bloat and of old statistics
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum, last_analyze
FROM pg_stat_user_tables WHERE schemaname = 'public' ORDER BY n_dead_tup DESC;

Under normal conditions, autovacuum takes care of it on its own. It becomes a problem when a table receives a huge number of updates, or when a transaction has been open for hours and prevents the cleanup of dead rows it might still need. And here we reach this module's limit: the reason for all of this —why the engine keeps several versions of each row and why an open transaction blocks the cleanup— is the MVCC model, and it's studied in module 9. For now, keep the operational part: ANALYZE after any bulk load, and a spiking n_dead_tup as an alarm signal.

  1. A six-step method

This is what really has to be taken away from the module. Faced with a slow query:

  1. Reproduce and measure. Get the exact query with its real parameters and run it with EXPLAIN (ANALYZE, BUFFERS). With no measurement there's no diagnosis.
  2. Locate the guilty node. Look for the node with the most actual time multiplied by loops, and the one reading the most blocks. It's almost always a single one and it's right at the bottom.
  3. Compare estimated with actual. If the estimated and actual rows diverge a lot, the problem is the statistics: ANALYZE, and go back to step 1. Don't carry on optimizing over a plan based on false data.
  4. Look at what it's reading too much of. A Seq Scan over a large table with an enormous Rows Removed by Filter = an index is missing or the condition isn't sargable. Sort ... Disk = a sorting index or memory is missing. A Nested Loop with thousands of loops = a bad estimate on the left branch.
  5. Apply ONE single intervention and measure again. Rewriting (08-04) before indexing (08-02); indexing before changing the architecture. One change at a time, or you won't know which one worked.
  6. Verify that the plan changed, not just that the time dropped (it may have dropped because of the cache). And weeks later, check in pg_stat_user_indexes that the index is still being used.

Common Mistakes and Tips

  • Running EXPLAIN ANALYZE over an UPDATE or a DELETE with no transaction. It runs it for real. BEGIN ... ROLLBACK, always.
  • Reading the plan top to bottom as if it were code. It's read from the inside outwards: the most indented node goes first. And don't forget to multiply by loops: a 0.08 ms node with 20,000 iterations is 1.6 seconds, and it's usually the culprit.
  • Comparing the cost of two engines, or of two servers with different configurations. They're arbitrary, relative units.
  • Trusting time alone on a shared machine. Use BUFFERS: the blocks read are reproducible. And don't measure just once: the first run fills the cache.
  • Leaving enable_seqscan = off switched on. It's for diagnosing, never for production: you force the planner to get the rest of its decisions wrong. And don't optimize without a prior ANALYZE: if the estimates are wrong, everything you build on top is wrong.
  • Tip: keep the "before" plan. A file with the EXPLAIN from before the change is the only objective proof that you've improved anything.
  • Tip: analyse with real production data. A plan over 100 development rows says absolutely nothing about 20 million, and the problems that only appear at three in the morning can't be reproduced by hand: that's what auto_explain is for.

Exercises

Exercise 1

Read this plan, taken from a real-sized GreenStore (2 million orders, not this lesson's test bench):

Nested Loop  (cost=0.42..248301.55 rows=38 width=48) (actual time=0.055..9412.331 rows=18422 loops=1)
  Buffers: shared hit=1204885
  ->  Seq Scan on orders o  (cost=0.00..41250.00 rows=38 width=25)
                            (actual time=0.021..1842.117 rows=18422 loops=1)
        Filter: (status = 'pending'::text)
        Rows Removed by Filter: 1981578
  ->  Index Scan using customers_pkey on customers c  (cost=0.42..5.44 rows=1 width=23)
                                                      (actual time=0.004..0.004 rows=1 loops=18422)
        Index Cond: (id = o.customer_id)
Planning Time: 0.412 ms
Execution Time: 9421.008 ms
  1. In what order do the nodes run?
  2. What's the main problem and what signal gives it away?
  3. How much total time does the Index Scan really consume?
  4. Propose two interventions, in the order you'd try them.

Exercise 2

Over orders_large, already created and indexed by customer_id, predict before running anything which node the planner will choose in each case, and then check it with EXPLAIN ANALYZE.

SELECT id FROM orders_large WHERE customer_id = 500;                      -- a)
SELECT COUNT(*) FROM orders_large WHERE status = 'delivered';             -- b)
SELECT customer_id FROM orders_large WHERE customer_id BETWEEN 1 AND 100; -- c)
SELECT id FROM orders_large WHERE customer_id + 0 = 500;                  -- d)

Exercise 3

Write the full sequence of commands —precautions included— to analyse this DELETE's performance without modifying the data, and say what you'd look for in the plan.

DELETE FROM orders_large WHERE order_date < '2020-01-01';

Solutions

Solution 1

1. The order. First the Seq Scan over orders (the deepest node of the left branch); for each row it produces, the Nested Loop runs the Index Scan over customers; the Nested Loop emits the result.

2. The problem is a catastrophically wrong estimate. The signal is in the Seq Scan: rows=38 estimated against rows=18422 actual, almost 500 times more. With 38 rows, a Nested Loop is the perfect choice; with 18,422 it becomes 18,422 index lookups. And there are two supporting symptoms: Rows Removed by Filter: 1981578 (two million rows are read to keep 0.9 %, so an index on status is missing) and Buffers: shared hit=1204885, more than a million blocks read to return 18,422 rows.

3. The Index Scan's time. actual time is per execution: 0.004 ms × 18,422 loops74 ms. In other words, the Index Scan isn't the problem: of the 9,421 ms, 1,842 are the Seq Scan, 74 the Index Scan, and the rest is eaten by the machinery of repeating the loop 18,422 times. Forgetting to multiply by loops is the most common reading mistake.

4. The interventions, in order. First, ANALYZE orders; and look at the plan again: it's free, instantaneous and attacks the root cause — if the estimate becomes correct, the planner will swap the Nested Loop for a Hash Join on its own and the time will fall without touching anything else. Then, if the estimate was already correct, an index for the pending ones, ideally partial: CREATE INDEX ... ON orders (customer_id) WHERE status = 'pending';, because they're less than 1 % and that way the index is tiny (08-02).

Solution 2

# Expected node Why
a) customer_id = 500 Index Scan 10 rows out of 2,000,000: 0.0005 % selectivity. The ideal case
b) status = 'delivered' Parallel Seq Scan + Gather under an Aggregate It returns 20 % of the table; well above the selectivity threshold (08-03). Even if there were an index, it wouldn't use it
c) customer_id BETWEEN 1 AND 100 Bitmap Index Scan + Bitmap Heap Scan, or Index Only Scan Around 1,000 scattered rows: too many for random jumps one by one, too few to read the whole table. And since only customer_id is requested, and it's in the index, it's a candidate for an Index Only Scan
d) customer_id + 0 = 500 Parallel Seq Scan It isn't sargable (08-04): there's an operation on the filtered column, and the index is ruled out. Same result as a), around 1,600 times slower. It's the exercise's most instructive experiment: same result, same index available, and a + 0 that cancels everything

Solution 3

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM orders_large WHERE order_date < '2020-01-01';
ROLLBACK;

The key precaution is the BEGIN ... ROLLBACK: EXPLAIN ANALYZE really runs the DELETE, and without the transaction you'd lose the rows. What to look for in the plan:

  • How it locates the rows. With no index on order_date, a Seq Scan with an enormous Rows Removed by Filter. With an index, a Bitmap Heap Scan — and since the data was generated in chronological order, the rows to delete are physically together, which is the best possible scenario.
  • How many rows it deletes. The delete node's actual rows: around 285,000 (365 days × 782 a day). And the estimated/actual comparison, as always.
  • A consideration this module can't resolve: a DELETE of 285,000 rows leaves 285,000 dead versions that VACUUM will have to clean up, and it holds locks for the whole transaction. That's why mass deletions are done in batches, and why partitioning (08-04) is so attractive for historical data: deleting a whole year is detaching a partition, not running a DELETE.

Module conclusion

You close module 8 with the method, not just with the rules:

  • EXPLAIN estimates; EXPLAIN ANALYZE runs and measures — and that's why an UPDATE or a DELETE has to be wrapped in BEGIN ... ROLLBACK.
  • A plan is a tree read from the inside outwards. cost is in relative units, rows and width are estimates, and actual time and rows are averages per execution: they have to be multiplied by loops.
  • The most useful signal of all is the divergence between the estimated and the actual rows. When it's large, the plan is well chosen for a reality that doesn't exist, and the cause is usually ANALYZE.
  • You can recognise the fifteen usual nodes, from the Seq Scan to the Gather, and what it means when Rows Removed by Filter, Heap Fetches or Sort Method: external merge Disk appear. And you know that BUFFERS is a better metric than time on any shared machine: the blocks read are reproducible.
  • You've seen it measured, not told: over two million rows, the same query goes from 16,250 blocks and ~142 ms to 13 blocks and ~0.09 ms. Whereas over GreenStore's 20 products the index costs ten times more than the Seq Scan, and the planner is right to ignore it. And 07-05's promise is verified with its name in the plan: Hash Semi Join.

And with that module 8 closes. In five lessons you've gone from "I don't know why this is slow" to having a procedure: you know what a B-tree is and why 15 million rows fit in 3 levels; you know GreenStore had eleven indexes nobody created and eleven uncovered foreign keys, because PostgreSQL doesn't index them; you know how to create composite, partial, expression and covering indexes, and how to choose between B-tree, hash, GIN, GiST, BRIN and SP-GiST; you know —and this is the rarest thing to find— when not to index; you know how to rewrite a query to make it sargable, spot an N+1 and update the planner's statistics; and you know how to read an execution plan and decide with data.

But there's an assumption we've kept for eighty-odd lessons without saying it out loud: that we're the database's only user. Everything you've measured here assumes nobody else is reading or writing at the same time, that no row changes while you're querying it and that no UPDATE is competing with yours for the same order line. That's false in any real system: in GreenStore, a customer confirms an order while the warehouse updates the same product's stock and the analyst fires off the monthly report over those very tables. In module 9, Transactions, you'll see what a transaction is and the four ACID guarantees, how they're controlled with BEGIN, COMMIT, ROLLBACK and SAVEPOINT, what isolation levels are and which anomalies each one allows, and how locks and deadlocks work — including, at last, the MVCC model that explains this lesson's bloat and why CREATE INDEX CONCURRENTLY had to exist.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved