PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 15: Reading plans

Reading EXPLAIN and EXPLAIN ANALYZE

Level 4: Performance30 min

EXPLAIN shows the plan the optimizer chose and what it expected; EXPLAIN ANALYZE runs the query and adds what actually happened. Reading the two side by side is the core skill of PostgreSQL performance work.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Read a plan tree: nodes, costs, estimated and actual rows, loops
  • Recognise Seq Scan, Index Scan, Index Only Scan and Bitmap Heap Scan and when each wins
  • Use BUFFERS and spot the signals that matter: misestimates, discarded rows, sorts on disk

conceptThe concept

A plan is a tree. Each node pulls rows from its children; the top node returns them to you. Read it from the innermost, most indented node outward. Every node shows cost=startup..total in arbitrary planner units, the estimated rows and the average row width in bytes.

EXPLAIN ANALYZE executes the query and adds actual time=first..last in milliseconds, actual rows and loops. Times and rows are per loop: a node with rows=3 loops=1000 produced 3,000 rows. BUFFERS adds pages found in shared memory (hit) and read from the operating system (read). Wrap data-changing statements in BEGIN and ROLLBACK, because ANALYZE really executes them.

Scan types: a Seq Scan reads every page and is the right choice when many rows qualify. An Index Scan walks the index and visits the heap per match. A Bitmap Heap Scan first collects matching row locations from one or more indexes, sorts them by page, then reads each page once, which is ideal for "a few hundred rows scattered around". An Index Only Scan answers from the index alone when every needed column is in it and the pages are marked all-visible.

whyWhy it matters

Guessing at performance is how indexes pile up that nobody uses. The plan tells you exactly where the time went, whether the optimizer's estimate was wrong, and whether a change helped.

Use caseReal-world scenario

The customer page loads slowly. EXPLAIN ANALYZE shows a Seq Scan on orders discarding 9,983 of 10,000 rows to find 17. One index later, the same query reads two index pages and 16 heap pages.

Try itExamples

Run and edit these in the interactive lesson.

A plan without an index
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;
Seq Scan, 17 rows kept and 9,983 removed by the filter. That ratio is the signal that an index is missing.
The same query with an index
BEGIN;
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;
ROLLBACK;

About 45% of orders belong to customers 1 to 399 (ordering is skewed toward low ids). Visiting the index and then most of the table costs more than reading the table once.

When an index is not worth it
BEGIN;
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
EXPLAIN SELECT * FROM orders WHERE customer_id < 400;
EXPLAIN SELECT * FROM orders WHERE customer_id < 5;
ROLLBACK;
Index Only Scan
VACUUM orders;
BEGIN;
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id FROM orders WHERE customer_id BETWEEN 10 AND 20;
ROLLBACK;
Heap Fetches: 0 means the visibility map said every page was all-visible, so no table pages were read. Any write to a page clears its all-visible bit, even if the transaction rolls back, so try running the query again without the VACUUM after the UPDATE examples elsewhere in the course: heap fetches appear until VACUUM runs again.
Explaining a data change safely
BEGIN;
EXPLAIN (ANALYZE, COSTS OFF)
UPDATE products SET price = price * 1.1 WHERE category = 'toys';
ROLLBACK;

internalsUnder the hood

Costs come from settings such as seq_page_cost (1.0), random_page_cost (4.0, often lowered to about 1.1 on SSDs) and cpu_tuple_cost, combined with row estimates from pg_statistic. When estimates are wrong, every choice built on them (join order, join algorithm, memory) can be wrong too. In this course the Explain button in the playground draws these plans as a tree and flags the classic warning signs.

Avoid / preferDo this, not that

Avoid
EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'cancelled';
Prefer
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'cancelled';
ROLLBACK;

EXPLAIN ANALYZE really executes the statement. Wrap data changes in a transaction you roll back.

Avoid
-- "cost=0.00..1234.00, too high"
Prefer
-- compare estimated rows with actual rows on each node,
-- and look at where the actual time goes

Costs are planner units, not milliseconds, and only comparable between plans for the same query. Large gaps between estimated and actual rows point at the real problem.

PitfallsCommon mistakes

  • Reading only the top-line cost. Find the node where actual time jumps, and compare estimated to actual rows there.
  • Forgetting that actual rows and time are per loop. Multiply by loops before deciding a node is cheap.
  • Running EXPLAIN ANALYZE on an UPDATE or DELETE outside a transaction you roll back. It changes the data.

LimitsWhen not to use it

Do not tune from plans on a tiny development database. With little data the planner rightly prefers sequential scans; test against production-like volumes and statistics.

SecuritySecurity notes

EXPLAIN ANALYZE runs functions with side effects, and auto_explain writes plans, sometimes with parameter values, to the server log. Treat logs as sensitive and restrict who can read them.

ChallengeTry it yourself

Inside a transaction, create an index that lets the query for customer 42's orders use an index scan, and prove it with pgm.uses_index.

Show one solution
One solution
BEGIN;
CREATE INDEX ON orders (customer_id);
SELECT pgm.uses_index('SELECT * FROM orders WHERE customer_id = 42') AS uses_index;
ROLLBACK;

InterviewQuestions you should be able to answer

What is the difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN shows the chosen plan with estimated costs and rows without running the query. EXPLAIN ANALYZE executes it and adds actual timings, actual rows, loops and (with BUFFERS, default in 18) page access counts. Comparing estimated and actual rows per node reveals bad statistics. Because ANALYZE executes the statement, wrap writes in a transaction and roll back.
When does PostgreSQL choose a Bitmap Heap Scan instead of an Index Scan?
When it expects more than a handful of matching rows but far fewer than the whole table. The bitmap index scan collects matching row locations, sorts them by page, and the heap scan then reads each page once in physical order, avoiding repeated random reads of the same page. Bitmaps from several indexes can also be combined with BitmapAnd and BitmapOr. With very many matches the bitmap becomes lossy (page-level) and rows are rechecked.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation