Skip to content

The Explain button runs your query, the agent plan read does not

Two EXPLAIN forms with different consequences: the editor emits the executing one, and the agent path emits the planning one only.

A plan is cheap to ask for, which is why it gets asked for on connections where nothing else would be. On PostgreSQL that instinct has a hole in it, because EXPLAIN ANALYZE executes the query. Postgres does not model the run and report what it would have cost; it runs the statement, counts what happened, and hands back the tree with real row counts attached. For a SELECT the difference is a matter of load. For a statement that writes, the difference is the write.

So there are two statements here wearing one name, and the split has to be drawn in the product rather than left to the reader’s memory.

Reading a plan tree rendered from FORMAT JSON

The provider declares supportsExplain: true and explainFormat: 'postgres-json' in getCapabilities(), alongside defaultPort: 5432 and the rest of the PostgreSQL capability set. That second flag is the load-bearing one. The plan-only form is requested as EXPLAIN (FORMAT JSON) and comes back as a nested object, not as the indented text a terminal prints:

EXPLAIN (FORMAT JSON)
SELECT r.rental_id, c.last_name
FROM rental r JOIN customer c ON c.customer_id = r.customer_id
WHERE r.return_date IS NULL;

Each node carries its Node Type, its Plan Rows and Plan Width estimates, its startup and total cost, and its children. A viewer can therefore lay the tree out and size the nodes by estimated cost without parsing indentation, and without a dialect-specific text reader that breaks when the server changes its wording. The text form is a rendering of the same data; the JSON form is the data.

What this form does not contain is anything measured. Every number in it is the planner’s estimate. That is exactly what makes it safe, and exactly what makes it insufficient when the estimate is the thing you distrust.

Why EXPLAIN ANALYZE executes the query on Postgres

Add ANALYZE and every node gains Actual Rows, Actual Loops and actual timing next to the estimate. That pairing is the whole reason anyone reaches for it: a node estimating a single row and returning hundreds of thousands explains a nested loop that should have been a hash join, and no amount of staring at the plan-only tree will tell you which node it is.

The cost is that the statement ran. Under the covers there is no third mode. The executor is the executor, the buffers were read, the timing is real because the work was real, and any side effect the statement carries has happened by the time the plan is rendered. On a large aggregate that means the production server just did the aggregate. On something that writes, it means the write is committed unless you wrapped it yourself.

Two things in the product exist because of that. Plan inspection is a query like any other, so a slow one is cancellable: a statement issued with a query id records its backend PID, and cancelling calls pg_cancel_backend(pid) on a fresh pooled client, exposed at POST /api/db/cancel. And a statement timeout or an operator cancel both come back from PostgreSQL as canceling statement due to ..., which mapDatabaseError() matches before its timeout branch, so both arrive as a cancellation rather than a timeout. Cancelling stops the rest of the work. It does not unhappen what already ran.

The data-modifying CTE that makes the split matter

On most engines the argument above stays theoretical, because a plan request is usually made against a SELECT. PostgreSQL removes that comfort. A statement can lead with WITH and still write:

WITH expired AS (
  DELETE FROM sessions WHERE last_seen < now() - interval '30 days'
  RETURNING user_id
)
INSERT INTO session_audit (user_id, removed_at)
SELECT user_id, now() FROM expired;

That is one statement. It reads like a query, it begins with the keyword queries begin with, and running EXPLAIN (ANALYZE, ...) on it deletes the rows and writes the audit trail. There is no dry-run flag that suppresses the effect.

This shape has bitten the surrounding machinery before, which is why it is treated as a first-class case rather than an edge one. Statements sent from the editor collect an appended LIMIT when they are a bare SELECT or CTE-SELECT carrying no bound already - 500 rows by default, 100,000 in unlimited mode - and a statement leading with WITH is typed by the keyword its CTE list actually operates. Type it wrong and the appended bound applies to the rows the statement writes: at most 500 of them committed, reported back as a truncated result set. The classifier reads that text under PostgreSQL’s own grammar for exactly this reason, and a data-modifying CTE is left unbounded and untouched.

The same fact drives the plan decision. If a WITH statement can be a write, then “it starts with WITH, the plan is safe” is not a rule, and no amount of pattern matching on the text can be trusted to make it one.

Which surface emits which form

The rule is drawn at the surface, not inside a heuristic.

Surface Statement emitted Executes
Editor Explain action EXPLAIN (ANALYZE, ...) Yes
Agent plan inspection EXPLAIN (FORMAT JSON) No

The editor Explain action emits the ANALYZE form and therefore executes the statement; the agent path uses EXPLAIN with FORMAT JSON only, because the executing form stays behind an approval-gated descriptor no agent tool reaches. That descriptor has a name, sql.explain.analyze, and nothing in the agent tool layer names it. The editor form is deliberate: a person pressed a button asking for real timings, and giving them estimates instead would be a different answer to the question they asked.

Agent AUTO mode has a second reason to hold the line. Its read-only boundary is the database, not a SQL parser: each statement runs as BEGIN READ ONLY, SET LOCAL statement_timeout, exactly one statement on the extended query protocol, then ROLLBACK and DISCARD ALL, with PostgreSQL itself refusing a write with SQLSTATE 25006 and a multi-command string with SQLSTATE 42601. That containment is real but it is not a licence to relax the descriptor list, because SET TRANSACTION READ WRITE is accepted inside BEGIN READ ONLY - what stops it is that it can only ever be the one statement the protocol allows before the rollback. A gate that depends on two mechanisms agreeing is worth more than one that depends on either. AUTO mode also requires a least-privilege role: opening the profile probes the role and refuses with PROFILE_PRIVILEGES_TOO_BROAD unless superuser and membership of pg_read_server_files, pg_write_server_files and pg_execute_server_program all read back false. Agent PLAN mode does not enter this discussion at all: it holds no tools, executes nothing, and drafts a statement for a person to run.

Choosing deliberately on a production connection

Ask for the plan-only form when you want to know what the planner intends: which index it chose, whether the estimate on a filter is plausible, whether a join order changed after a schema edit. It is free of side effects and safe on a connection you would not otherwise experiment on.

Ask for the ANALYZE form when the estimate is the suspect and you need the actual row counts beside it - and read the statement first, because that is the moment you are agreeing to run it. On a write, or on a WITH that turns out to write, open a transaction yourself, or run it on a copy.

Every number in the plan-only tree is an estimate. That limitation is the price of the guarantee that reading it changed nothing, and it is published on the feature pages and the engine page rather than left in a tooltip.