By WebNest Studio Engineering Team
PostgreSQL Tutorial
Reading Query Plans with EXPLAIN in PostgreSQL
EXPLAIN shows you the execution plan PostgreSQL's query planner has chosen for a statement — which scan methods, join strategies, and orderings it will use — without actually running the query. Adding ANALYZE actually executes the statement and reports real timing and row counts alongside the planner's estimates, making it the single most important tool for diagnosing why a query is slow.
Why it matters
When a query is slow, guessing at the cause wastes hours. EXPLAIN shows exactly what PostgreSQL does: which tables it scans, which indexes it uses and where the time goes. Reading a plan is the core skill of database performance work.
EXPLAIN vs. EXPLAIN ANALYZE
Plain EXPLAIN SELECT ...; only estimates cost and row counts based on table statistics — it never actually executes the query, which makes it safe to run even on statements with side effects like UPDATE or DELETE. EXPLAIN ANALYZE SELECT ...; actually runs the query and reports real elapsed time and actual row counts per plan node, letting you compare "estimated rows" against "actual rows" — a large mismatch there is one of the most common signals of stale table statistics or a poor plan choice.
Adding BUFFERS for I/O Visibility
EXPLAIN (ANALYZE, BUFFERS) SELECT ...; additionally reports how many disk buffers were hit in cache versus read from disk for each plan node. A high number of "read" (as opposed to "hit") buffers on a hot, frequently-run query can point to a table that doesn't fit comfortably in memory or an index that isn't being leveraged effectively.
Reading the Plan Tree
A plan is a tree of nodes, read from the innermost (bottom) node outward. Common node types include Seq Scan (reads the whole table — fine for small tables, a red flag on large ones without a matching WHERE-friendly index), Index Scan / Index Only Scan (uses an index to jump directly to matching rows), and join nodes like Nested Loop, Hash Join, and Merge Join, each suited to different table sizes and join conditions. Each node reports estimated cost (arbitrary planner units), estimated rows, and — with ANALYZE — actual time and actual rows.
Example
EXPLAIN ANALYZE, BUFFERS revealing an index scan with real timing
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM products WHERE category_id = 1 ORDER BY price DESC;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------
Sort (cost=8.32..8.35 rows=12 width=64) (actual time=0.041..0.042 rows=2 loops=1)
Sort Key: price DESC
Buffers: shared hit=3
-> Index Scan using idx_products_category on products (cost=0.15..8.10 rows=12 width=64)
(actual time=0.020..0.022 rows=2 loops=1)
Index Cond: (category_id = 1)
Buffers: shared hit=3
Planning Time: 0.112 ms
Execution Time: 0.061 ms
Common Mistakes
- Running plain EXPLAIN and treating estimated rows as ground truth — only EXPLAIN ANALYZE shows what actually happened, and large estimate/actual gaps signal outdated statistics.
- Running EXPLAIN ANALYZE on a destructive statement (UPDATE/DELETE) in production without wrapping it in a transaction and rolling back — ANALYZE genuinely executes the statement.
- Seeing a Seq Scan and assuming it is always bad — on a small table, or when most rows match the condition anyway, a sequential scan can legitimately be the fastest option.
- Ignoring the Buffers output and focusing only on Execution Time, missing that a query is slow specifically because it is reading heavily from disk rather than cache.
Exercise
This exercise uses PostgreSQL's EXPLAIN output, so it needs a PostgreSQL server. For a large orders table, write the command that shows the actual execution plan, with timings and buffer usage, of the query that finds the orders of customer 42. Then create the index that the plan will probably show to be missing, and run the same command again.
Expected result: the first plan shows a sequential scan over the whole table; after the index is created, the plan shows an index scan or bitmap index scan and a much lower execution time.
-- TODO: show the real plan, with timing and buffers, for:
-- SELECT * FROM orders WHERE customer_id = 42;
-- TODO: create an index on orders.customer_id
-- TODO: show the plan again
Show hints
- The options are given in parentheses:
EXPLAIN (ANALYZE, BUFFERS). ANALYZEreally executes the query, so take care with statements that change data.
Show solution
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;
Quick quiz
1. What is the difference between EXPLAIN and EXPLAIN ANALYZE?
- A. There is none
- B. EXPLAIN runs the query twice
- C. EXPLAIN shows the estimated plan without running the query; EXPLAIN ANALYZE runs it and shows actual times and row counts
- D. EXPLAIN ANALYZE only works on SELECT
Show answer
C. Because ANALYZE executes the statement, an UPDATE or DELETE under it really changes data.
2. What does "Seq Scan" in a plan mean?
- A. The table is read from start to end, row by row
- B. An index was used
- C. The rows were sorted
- D. The query was cached
Show answer
A. It is normal for small tables and for queries that return most of the rows.
3. A plan shows an estimate of 10 rows where the actual number is 500,000. What does that suggest?
- A. The index is corrupt
- B. The query is wrong
- C. The server needs more memory
- D. The table's statistics are out of date, and ANALYZE should be run
Show answer
D. The planner chooses plans from its estimates, so a bad estimate often leads to a bad plan.
Interview questions
How do you read a PostgreSQL query plan?
Show answer
A plan is a tree, and execution starts at the most indented nodes and flows upwards. Each node shows its type, such as Seq Scan, Index Scan, Hash Join or Sort, with an estimated cost and row count; with ANALYZE it also shows the actual time, rows and loops. Look for the node where most of the time is spent, for sequential scans on large tables with selective filters, and for large gaps between estimated and actual rows.
Why might PostgreSQL not use an index that exists?
Show answer
The planner may judge a sequential scan cheaper, which is right when the query returns a large share of the table or the table is small. The condition may not match the index: a function or cast applied to the column, a leading wildcard in LIKE, or a composite index whose leading column is not filtered. Outdated statistics can also mislead it. EXPLAIN shows what was chosen, and ANALYZE on the table refreshes the statistics.
Key Points to Remember
- EXPLAIN shows the planner's estimated plan without executing; EXPLAIN ANALYZE actually runs the query and reports real timings.
- EXPLAIN (ANALYZE, BUFFERS) additionally shows cache hits versus disk reads per plan node.
- Plans are trees read from the innermost node outward; Seq Scan, Index Scan, and join node types each imply different strategies.
- A large gap between estimated and actual row counts is a strong signal of stale statistics or a suboptimal plan.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering