EXPLAIN Options and Output Formats #
EXPLAIN is not one tool but a dozen: each option adds a specific kind of evidence to the plan tree, and choosing the wrong set is how teams end up arguing about a plan that is missing the one field that would have settled the question.
Plain EXPLAIN shows only what the planner intended. ANALYZE adds what happened; BUFFERS adds where the I/O went; SETTINGS adds the configuration that shaped it; and the newer options — WAL, MEMORY, SERIALIZE, GENERIC_PLAN — close the gaps between plan output and what a client actually experiences. This page maps each option to the question it answers, explains what each costs, and covers when the machine-readable formats are the better choice. It is part of reading and interpreting query plans, and every other page in that section assumes you can capture the right plan.
When to Use Each Option #
The options fall into three groups by what they require.
Planning only — the statement is not executed:
COSTS(on by default): startup and total cost, estimated rows, and width. Costs are unitless planner values, as explained in cost estimation models.VERBOSE: output column lists per node, schema-qualified relation names, and details such as which worker ran which part of a parallel plan.SETTINGS(PostgreSQL 12+): every planner-affecting parameter whose value differs from the built-in default. The single most useful option for reproducing someone else’s plan.GENERIC_PLAN(PostgreSQL 16+): plans a statement containing$1-style placeholders without values, showing the plan a prepared statement would get once it switches to a generic plan. Cannot be combined withANALYZE.MEMORY(PostgreSQL 17+): memory used by the planner itself.SUMMARY: addsPlanning Time; on by default withANALYZE.
Execution — the statement runs:
ANALYZE: actual time, actual rows and loops for every node, plusExecution Time. The statement executes fully, including side effects.BUFFERS: shared, local and temporary block counts per node — hits, reads, dirtied and written — plus I/O timing whentrack_io_timingis on. Included automatically withANALYZEfrom PostgreSQL 18.WAL(PostgreSQL 13+): WAL records, full-page images and bytes generated per node. Only meaningful for writes.TIMING: on by default withANALYZE.TIMING OFFkeeps row counts and loops but skips per-node clock reads.SERIALIZE(PostgreSQL 17+): converts the result rows to their output format — the work a real client forces — and reports that time and the bytes produced, without sending them.
Presentation:
FORMAT TEXT | JSON | YAML | XML: the same information in a human-readable indented tree or a structured document.
-- A complete capture for a read query
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SERIALIZE)
SELECT o.id, o.total, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.placed_at >= now() - interval '7 days';
Annotated Output: Every Option on One Node #
The fields each option adds appear in predictable places. Here is one scan with the full set:
Seq Scan on public.orders o (cost=0.00..24812.00 rows=58210 width=24)
(actual time=0.018..96.402 rows=61034 loops=1)
Output: o.id, o.total, o.customer_id -- VERBOSE: columns this node emits
Filter: (o.placed_at >= (now() - '7 days'::interval))
Rows Removed by Filter: 938966 -- ANALYZE
Buffers: shared hit=4102 read=8710 -- BUFFERS: 4102 cached, 8710 read from disk
I/O Timings: shared read=61.883 -- BUFFERS + track_io_timing: ms spent reading
WAL: records=0 bytes=0 -- WAL (only shown when requested)
Settings: random_page_cost = '1.1', work_mem = '64MB' -- SETTINGS: non-default planner GUCs
Planning:
Buffers: shared hit=212 -- catalog lookups during planning
Memory: used=1872kB allocated=2048kB -- MEMORY (PostgreSQL 17+)
Planning Time: 0.412 ms -- SUMMARY
Serialization: time=38.214 ms output=5812kB format=text -- SERIALIZE (PostgreSQL 17+)
Execution Time: 142.771 ms
Breakdown:
Output:tells you which columns a node carries upward. WideOutput:lists under sorts and hashes explain largeMemory Usage, and a column you did not expect usually means aSELECT *hidden in a view.Buffers: shared hit / readseparates cache hits from disk reads. The method for ranking nodes by it is in using BUFFERS to find I/O hotspots.I/O Timingsappears only whentrack_io_timing = on. It converts block counts into milliseconds of actual waiting.Settings:lists only non-default values. An emptySettings:line is itself evidence: the plan was produced on defaults.Planning: Bufferscounts catalog I/O. A large value on a query touching hundreds of partitions shows planning pays for catalog access too.Serialization:is time the server spends converting rows to text or binary — work thatExecution Timeincludes from PostgreSQL 17 only whenSERIALIZEis requested. A 38 ms serialization on a 142 ms query is a quarter of the cost, invisible without it.
Option Internals and Overhead #
ANALYZE instrumentation. Each node is wrapped in counters that record rows and loops, and, with TIMING on, two clock reads per row passing through the node. On systems where the clock source is slow — some virtual machines — that overhead can double execution time for row-heavy plans, and it inflates nodes that process many cheap rows far more than nodes that process few expensive ones. pg_test_timing measures the clock cost; TIMING OFF removes it while keeping actual rows and loops.
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS)
SELECT count(*) FROM events WHERE kind = 'view';
-- Aggregate (actual rows=1 loops=1)
-- -> Seq Scan on events (actual rows=41822031 loops=1)
-- Execution Time: 4102.6 ms (vs 7881.2 ms with TIMING ON on this VM)
BUFFERS accounting. Buffer counts are cumulative: a parent node’s counts include its children’s. To get a node’s own I/O, subtract the sum of its children. The same inclusive rule applies to actual time, which is why computing exclusive time is the first thing plan tools do with JSON output.
Parallel plans. With VERBOSE, each parallel-aware node lists per-worker lines (Worker 0: actual time=… rows=…). Without it, the node shows totals divided by loops, which is easy to misread — covered in parallel query execution.
GENERIC_PLAN. The planner treats each $n as an unknown of its inferred type and plans exactly as it would for a cached generic plan, so Index Cond: (tenant_id = $1) and averaged row estimates appear. It is the fastest way to see what the custom-to-generic switch will produce without preparing anything; the details are in EXPLAIN GENERIC_PLAN for parameterized SQL.
Format cost. Formats differ only in serialization of the plan itself. JSON is larger than text by several times and includes every field with explicit keys, including zero-valued counters that text omits.
Memory, I/O and Safety Considerations #
Side effects. EXPLAIN ANALYZE executes the statement. For INSERT, UPDATE, DELETE and MERGE, rows change, triggers fire, and row locks are taken. Wrap the capture in a transaction and roll it back. Even then, sequences advance, and anything a trigger did outside the database is not undone.
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL)
UPDATE orders SET status = 'archived' WHERE placed_at < '2025-01-01';
ROLLBACK;
WAL for writes. The WAL: line shows records, fpi (full-page images) and bytes. A large fpi count right after a checkpoint is why the same update can generate several times more WAL at one moment than another; that write amplification is also what fills replication links and slows index maintenance.
Memory of ANALYZE itself. Instrumentation is per node and small, but a plan with many thousands of nodes — heavily partitioned tables — consumes noticeable memory both for planning (visible with MEMORY) and for per-node counters.
Production capture. For statements you cannot run by hand, the equivalent options are available through auto_explain (log_analyze, log_buffers, log_wal, log_settings, log_timing, log_format = json). The setup is in capturing ORM plans with auto_explain.
-- Session-level capture of one application code path
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 0;
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;
SET auto_explain.log_settings = on;
SET auto_explain.log_format = 'json';
Step-by-Step Capture Workflow #
-
Decide whether execution is safe. Read-only query: proceed. Write: use a transaction with
ROLLBACK, on a replica-like environment if locks matter. Cannot execute at all: start withEXPLAIN (VERBOSE, SETTINGS)orGENERIC_PLAN. -
Turn on I/O timing for the session if you have permission:
SET track_io_timing = on; -
Capture the core set:
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE) <query>; -
Add the situational options.
WALfor writes;SERIALIZEwhen the result is large or includes wide text, JSON or bytea columns;MEMORYwhenPlanning Timeis significant. -
Check for instrumentation distortion. If a row-heavy node dominates, re-run with
TIMING OFFand compareExecution Time. A large drop means per-node times were inflated by clock reads. -
Export for tools and history. Capture
FORMAT JSONalongside text for anything you will compare later, feed to a visualiser, or store with an incident. The settings, WAL and serialize options guide covers reading those three in depth. -
Record the server version. Field availability and some output names differ across major versions;
SELECT version();belongs next to every saved plan.
Comparing plans across major versions #
Upgrades change plan output in ways that look like regressions but are not. PostgreSQL 18 includes buffer counts automatically with ANALYZE, adds Index Searches to index scan nodes, and reports fractional row counts for nodes executed in many loops, where earlier versions rounded actual rows to an integer per loop. PostgreSQL 17 added SERIALIZE and MEMORY. PostgreSQL 16 introduced GENERIC_PLAN and new join node names such as Hash Right Anti Join. A diff between a PostgreSQL 15 plan and a PostgreSQL 18 plan of the same query therefore shows many textual changes even when the plan shape is identical.
Compare structure rather than text: node types, relation and index names, join conditions and the order of children. JSON output makes that straightforward, and it tolerates new keys gracefully. When a real plan change appears after an upgrade, capture SETTINGS on both versions as well — defaults for some planner parameters have changed across releases, and a changed default shows up in Settings: on one side only if someone had overridden it on the other.
Choosing options for a shared incident record #
For a plan that will be discussed by several people or attached to a ticket, the most useful single capture is EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, FORMAT JSON) stored alongside the same capture in text format, the server version, the time of capture, and whether it was taken on a primary or a replica. The text version is what people read; the JSON version is what tools and later comparisons use; the context lines prevent the most common misreading, which is comparing a warm-cache replica plan with a cold-cache primary plan and attributing the difference to the query.
Common Pitfalls #
Running EXPLAIN ANALYZE on a write outside a transaction. The rows change for real. Diagnostic signal: a table that is suddenly missing data after an investigation. Fix: make BEGIN; EXPLAIN ANALYZE …; ROLLBACK; a reflex for any statement that is not a plain SELECT, and check for data-modifying CTEs inside what looks like a read.
Comparing plans captured with different options. A plan with BUFFERS from one server and without it from another invites guesses about I/O. Diagnostic signal: a debate about cache effects in which only one side has buffer counts. Fix: agree on one option set per investigation, including SETTINGS.
Treating per-node actual time as exclusive. Times include children. Diagnostic signal: a top-level node that “takes” nearly the whole execution time. Fix: subtract children’s actual total time × loops, or let a JSON-based tool compute exclusive time.
Trusting timings on a slow clock source. Instrumentation overhead inflates row-heavy nodes. Diagnostic signal: Execution Time far above the unobserved query’s runtime. Fix: compare with TIMING OFF and check pg_test_timing.
Assuming Execution Time is client latency. Serialization and network are outside it. Diagnostic signal: the server says 140 ms and the application logs 900 ms. Fix: add SERIALIZE, then measure network and driver time separately.
Reading GENERIC_PLAN output as the plan the application gets. Whether the application uses the generic plan depends on the plan cache mode and the five-execution rule. Diagnostic signal: a generic plan that differs from what auto_explain logs. Fix: treat GENERIC_PLAN as the candidate, and confirm with pg_prepared_statements or auto_explain.
Frequently Asked Questions #
Which EXPLAIN options should I use by default? #
For a query that is safe to execute, use EXPLAIN (ANALYZE, BUFFERS, SETTINGS). ANALYZE gives actual rows and time, BUFFERS gives I/O per node, and SETTINGS records any planner-relevant parameter that differs from the default, which makes the plan reproducible by someone else. Add VERBOSE when you need output columns or schema-qualified names.
Does EXPLAIN ANALYZE execute data-modifying statements? #
Yes. EXPLAIN ANALYZE on INSERT, UPDATE, DELETE or MERGE performs the change, fires triggers and takes locks. Wrap it in BEGIN and ROLLBACK when you only want the plan and its timings, remembering that sequences still advance and locks are held until the rollback.
Why does EXPLAIN ANALYZE report a different time than my application sees? #
Execution Time excludes sending rows to the client and network time, and without SERIALIZE it excludes converting rows to their output format. Instrumentation overhead can also inflate it. SERIALIZE measures output conversion, and TIMING OFF removes most per-node clock overhead while keeping row counts.
When should I use FORMAT JSON instead of text? #
Use JSON whenever a program will read the plan: plan visualisers, regression tests that compare node types, auto_explain logs you want to query, or scripts that compute exclusive time per node. Text is better for people, because it is compact and indented.
Related #
- Capturing Plans for Long-Running Queries — getting plans for queries in flight or already finished, without re-running them
- EXPLAIN MEMORY and Planning Buffers — measuring the planner’s own memory and catalog reads, and when planning is the bottleneck
- EXPLAIN VERBOSE Output Columns and Workers — per-node output lists and per-worker statistics that plain EXPLAIN never shows
- Parsing EXPLAIN JSON Output — extracting exclusive time and hot nodes with SQL
- EXPLAIN SETTINGS, WAL and SERIALIZE Options — the three options that explain gaps plain ANALYZE leaves
- EXPLAIN GENERIC_PLAN for Parameterized SQL — planning
$1queries without values - Reading Cost vs Actual Time in EXPLAIN ANALYZE — interpreting the two numbers ANALYZE puts side by side
- Capturing ORM Plans with auto_explain — the same options applied to production traffic