DuckDB plans are estimates here, permanently
The timing form of EXPLAIN executes the statement it was asked to explain, so it is never emitted and no measured node timings are published.
Press Explain on a DuckDB query in Studio and you get a tree of operators, a join order, a filter list and a cardinality guess. You do not get a millisecond anywhere, and you will not get one in a later release.
EXPLAIN (ANALYZE, FORMAT JSON) INSERT INTO probe VALUES (42) was issued three
times against a fresh table. The table went from 0 rows to 1 to 2 to 3. Every
call really performed the insert, and an UPDATE behind it really changed the
row. Plain EXPLAIN (FORMAT JSON) on the same statement returned an INSERT plan
and left the count where it was.
What the JSON plan gives you
The provider declares explainFormat: "duckdb-json" and sends one statement:
EXPLAIN (FORMAT JSON) <query>. DuckDB answers a single row with two columns.
explain_key is physical_plan; explain_value is a JSON string, which the
provider parses into an array of nodes. A node has exactly three keys - name,
children, extra_info - and the array always has one root. Measured over six
statements, the deepest plan ran nine levels.
extra_info is where the content is, and it is not uniformly shaped. On
EXPLAIN (FORMAT JSON)
SELECT c.name, o.total
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE o.total > 10
ORDER BY o.total
LIMIT 3;
one SEQ_SCAN node answered:
{"Table":"warehouse.main.customers","Type":"Sequential Scan",
"Projections":["id","name"],"Estimated Cardinality":"5"}
Table is a string and Projections is an array of strings, inside the same
object. A reader that assumes strings silently drops the projection list, so the
rendering strategy joins an array’s members rather than printing [object Object]. The keys seen across the probed statements are Table, Type, Table Index, Projections, Filters, Expression, Conditions, Join Type,
Groups, Aggregates, Order By, Top, CTE Name, CTE Index and Estimated Cardinality.
Two boundaries around that. EXPLAIN (FORMAT JSON) on a statement DuckDB cannot
resolve throws rather than answering a payload - Catalog Error: Table with name nope_missing does not exist! - so a typo produces an error, not an empty plan.
And a multi-statement string runs only its first statement: runAndReadAll("SELECT 1 AS a; SELECT 2 AS b") answers [{"a":1}], with no error and no second result.
Explaining a pasted script explains the top of it.
The measurement, and what it is not
The analyze plan variant is never emitted: it executes the statement it was asked to explain, so DuckDB plans here carry structure with no timings, and that absence is permanent rather than pending.
That is the whole rule, and the insert count above is the whole evidence. It is
worth being precise about what is not the reason. Those three executing calls
each answered a {"result": "error"} payload, and it would be easy to file this
as a broken output format. It is not. On other runs the same form answers
explain_key = "analyzed_plan" with a real profile object carrying latency,
cpu_time, operator_timing, operator_cardinality, rows_returned and a
nested children - an object, not the array that physical_plan publishes. The
payload is intermittent. The execution is not.
So the Explain action turns the analyze form off for DuckDB rather than sending it and post-processing whatever comes back. There is no timing data to show, and none is fabricated. A control that cannot work is absent with its reason written where it would have been, which is the same rule the rest of the capability model runs on.
Why a later release makes this worse, not better
The obvious hope is that this is a version to wait out - that DuckDB will eventually emit clean analyze JSON and the timings will appear. Read the failure again and the hope inverts.
Today the analyze form is unusable in two ways at once: it runs your statement,
and it sometimes hands back a payload nobody can render. The second failure is
loud. It is what keeps a plausible-looking implementation from shipping and
quietly executing a DELETE the user asked only to see.
A release that fixes only the payload removes the loud half and keeps the dangerous one. The plan viewer would then look correct - a timed tree, operator by operator - while every press of Explain still ran what the user asked only to see. The trap gets harder to notice exactly as the output gets prettier. That is why this limitation is recorded as ours and permanent rather than as the engine’s and pending: it is a decision about what the button is allowed to do, not a bug report waiting on an upstream fix.
The safe use of the analyze form is the one where the execution is not a surprise: you already intend to run the statement, you run it yourself, and you read the profile as a side effect of a run you chose. That is a different action from “explain this”, and it stays outside the Explain button.
Reading structure when there are no numbers
A plan without timings is still worth opening, as long as you read it for the questions it can answer.
- Join order and join type.
ConditionsandJoin Typetell you what DuckDB decided to do with your predicate, which is usually the finding. - What got pushed down.
Filterson a scan node is the predicate arriving at the scan rather than above it, andProjectionsnames the columns that scan reads. - Where the shape is wrong. A
SEQ_SCANunder a node you expected to be fed by an index is visible without a clock.
Estimated Cardinality is the one number in the tree, and it needs reading twice.
It is an estimate, not a measurement, and a node need not carry it at all - in the
plan above, the root TOP_N carried only {"Top":"3","Order By":"o.total ASC"}.
An absent cardinality is absent, not zero, and it is rendered that way.
Treat the estimate as the optimizer’s belief rather than as a row count. DuckDB
publishes a second, unrelated estimate that shows why: after a delete on a
20,000,000-row table, duckdb_tables().estimated_size answered 1,076,480 where
count(*) answered 1,000,000, and a CHECKPOINT left it there. That is the
catalog’s estimate rather than the plan’s, but it is the same lesson, and it is
why the object tree counts with count(*) instead.
Where to get real timings instead
The timings you can have on DuckDB are wall-clock timings of runs you chose.
Each executed statement returns its own executionTime alongside rows,
fields and rowCount, so the editor times what you actually ran. That is a
whole-statement figure, not a per-operator one - it will tell you a query got
slower, never which node did it.
Nothing retrospective exists to fall back on. SELECT * FROM duckdb_queries()
answers Catalog Error: Table Function with name duckdb_queries does not exist!,
and duckdb_connections() answers the same way, so there is no slow-query log and
no session list to consult afterwards. Both panels stay empty permanently and
print DuckDB’s own reason rather than reporting a zero.
What monitoring does publish is resource evidence, which is often the thing you
wanted the timings for. pragma_database_size() reports database, WAL and memory
figures - as human-formatted strings such as "2.0 MiB", parsed to bytes for the
gauge - duckdb_memory() breaks usage down in real bytes, and
duckdb_temporary_files() answers with a real reading rather than a gap - it
answered [] on an idle database, which is a reading and not a blank.
And a query that is taking too long can be stopped:
DuckDBConnection.prototype.interrupt() exists in @duckdb/node-api 1.5.5-r.4
and is what cancellation calls. Measured, it halts a running scan with INTERRUPT Error: Interrupted! and leaves the connection usable for the next statement -
which matters more than usual on an engine with no session to KILL from a second
connection.
The full set of what DuckDB answers and what it declines is on its engine page.