How to read a PostgreSQL EXPLAIN ANALYZE plan
Quick answer: Run
EXPLAIN (ANALYZE, BUFFERS)and read the tree from the innermost node outward. For each node compare estimated rows (planner's guess) to actual rows (reality) — a large gap is the root cause of most bad plans. Watch for aSeq Scanwhere an index scan should be, and useBUFFERSto see shared-buffer hits versus misses.
EXPLAIN vs EXPLAIN ANALYZE
EXPLAINprints the plan the planner would choose, with estimates, and does not run the query.EXPLAIN ANALYZEactually executes the query and adds the real timing and actual row counts.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;
Because EXPLAIN ANALYZE runs the statement, be careful with writes — wrap
INSERT/UPDATE/DELETE in a transaction and roll back:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'x' WHERE id = 1;
ROLLBACK;
How do I read a plan node?
Each line is a node, and the numbers come in two pairs:
Index Scan using orders_customer_id_idx on orders
(cost=0.43..8.45 rows=1 width=64) (actual time=0.021..0.024 rows=1 loops=1)
cost=start..total— the planner's cost estimate in arbitrary units (not ms). The first number is startup cost, the second is total.rows=1(in thecost=...pair) — the planner's estimated row count.actual time=start..total— real time in milliseconds, cumulative and including child nodes.rows=1(in theactual...pair) — the actual rows returned.loops=N— how many times the node ran; actual rows and time are per loop, so multiply byloopsfor the true node total.
Reading inside out — deepest, most indented node first — is a useful habit for tracing where rows originate, but it isn't the execution order. Execution is demand-driven from the top: a parent pulls rows from its children as it needs them, and node semantics vary. A nested loop re-runs its inner child once per outer row; a hash join fully builds its hash table from the inner side before it probes with the outer. Treat "deepest first" as a reading aid, not a rule for when each node runs.
Because a parent node's cost and actual time are inclusive of its
children, do not add up per-node times — that double-counts. Read the query's
total runtime off the root (topmost) node.
The signal that matters: estimated vs actual rows
The single most useful thing in a plan is the gap between estimated and actual rows:
| Estimated rows | Actual rows | What it means |
|---|---|---|
| ~ actual | ~ actual | Planner is well-informed; trust the plan |
| 1–10 | 1,000,000 | Planner is guessing low — it likely picked a nested loop or index scan that is now catastrophic |
| 1,000,000 | 5 | Planner is guessing high — it may have chosen a Seq Scan or hash join it didn't need |
A big misestimate means the planner optimized for a table that doesn't exist.
The usual causes are stale statistics (run ANALYZE), or correlated columns the
planner estimates independently.
Seq Scan vs Index Scan
- Seq Scan reads the whole table. Correct when you need most of the rows or the table is small; a problem when you select a few rows from a big table.
- Index Scan walks an index to find matching rows — cheap for selective lookups.
- Index Only Scan answers from the index alone when the visibility map says
the heap page is all-visible. For pages it doesn't, it still visits the heap —
check the
Heap Fetches:line. A high count means you're paying for a plain index scan, which is common on a recently-updated table. - Bitmap Index Scan + Bitmap Heap Scan sits in between — good when a query matches a moderate fraction of rows.
BUFFERS tells you where the pages came from: shared hit = already in
PostgreSQL's shared buffers; shared read = missed those buffers (an OS
read() — which may still be served from the OS page cache, not the disk). A
huge shared read count still matters: the plan is touching far more data than
a well-indexed one would.
Why does AI-generated code write plan-blind SQL?
Your AI coding agent (Claude Code, Cursor, Copilot) can see your schema and your
query, but it never sees the plan. It doesn't know that orders has 40
million rows, that customer_id isn't indexed, or that a join will misestimate
and collapse into a nested loop. It writes SQL that is correct and idiomatic —
and then the planner does something the agent had no way to anticipate. The plan
is a runtime artifact of your data distribution and statistics, not a property of
the source.
How do I use plans routinely?
- Pull a plan for any query flagged as slow, not just when something breaks.
- Compare estimated to actual first; chase the largest gap.
- Add
BUFFERSto separate a CPU problem from an I/O problem. - Re-run
ANALYZEon the table before concluding an index is missing — stale stats fake a lot of "missing index" symptoms.
How DBGorilla helps
DBGorilla connects read-only and gives your AI coding agent (Claude Code, Cursor) the real plan for a query — the estimated-vs-actual rows, the scan types, the buffer counts — so it can explain why the query is slow in plain language instead of guessing from the SQL. It reads and explains; it does not run writes against production. Get started free →
FAQ
What is the difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN shows the estimated plan without running the query; EXPLAIN ANALYZE
executes it and adds real timing and actual row counts so you can compare
estimate to reality.
Why do estimated rows differ from actual rows?
Estimates come from statistics gathered by ANALYZE. They drift when stats are
stale or when predicates are correlated in ways the planner can't model. A large
gap is the classic bad-plan signal.
Is a Seq Scan always bad? No — it's correct for small tables or queries that read most rows. It's a problem when you select a few rows from a large table and an index scan would be cheaper.
What does loops mean?
How many times the node ran (often the inner side of a nested loop). Actual rows
and time are per loop — multiply by loops for the node's true total.