SQL EXPLAIN and Query Plans
EXPLAIN shows the steps the database will take to answer a query: which tables it scans, how it joins them, and how many rows it expects at each step.
Put EXPLAIN in front of a query and the database describes how
it would run it instead of running it. The answer is a
query plan: a tree of steps, each one feeding rows to the
step above. It is the quickest way to see what a query actually does, which
is not always what its SQL looks like it does.
explain
select product_name, price
from products
where price > 20
DuckDB returns one row with two columns. explain_key says
physical_plan, and explain_value holds the plan,
drawn as boxes:
┌───────────────────────────┐ │ PROJECTION │ │ ──────────────────── │ │ product_name │ │ price │ │ │ │ ~4 rows │ └─────────────┬─────────────┘ ┌─────────────┴─────────────┐ │ SEQ_SCAN │ │ ──────────────────── │ │ Table: │ │ memory.main.products │ │ │ │ Projections: │ │ price │ │ product_name │ │ │ │ Filters: price>20.0 │ │ │ │ ~4 rows │ └───────────────────────────┘
Reading a plan
Read it from the bottom up. Rows start at the bottom, where the tables are read, and flow up through each box to the result at the top.
-
SEQ_SCANreadsproducts. ItsProjectionsare the only columns it reads, and itsFiltersline is theWHEREclause: DuckDB checksprice > 20while scanning, so rows that fail never leave the scan. -
PROJECTIONpicks the output columns, in the order theSELECTlists them. -
~4 rowsis an estimate. The query really returns 5 rows. DuckDB plans before it runs, so every count with a~is a guess.
Joins and grouping add boxes with two inputs:
explain
select c.state, count(*) as orders
from customers as c
join orders as o on o.customer_id = c.customer_id
group by c.state
Now two SEQ_SCAN boxes sit side by side at the bottom, one per
table, and both feed a HASH_JOIN that shows its
Join Type and its Conditions. Above it,
HASH_GROUP_BY does the grouping, and lists the aggregates it
computes (count_star()). Its estimate is ~6 rows;
the query returns 7 states.
Steps named like __internal_compress_string_uinteger are DuckDB
packing values into a smaller type while it works, then unpacking them at
the end. They are an optimization you did not ask for and can ignore.
EXPLAIN ANALYZE runs the query
explain analyze
select product_name, price
from products
where price > 20 EXPLAIN ANALYZE runs the query, throws away its result, and
reports what happened. explain_key becomes
analyzed_plan, a Total Time appears at the top,
and every box shows the rows it actually produced and how long it took: the
scan now says 5 rows, with no ~. The scan is
labeled TABLE_SCAN here rather than SEQ_SCAN; it
is the same step.
Comparing estimates with real counts is how you find the step that does something you did not expect. That is the main use of a plan on small data.
Finding a fan-out
A join that matches each row to several rows multiplies them, and every count above it inflates. The plan shows exactly where.
explain analyze
select c.customer_id, count(*) as order_rows
from customers as c
join orders as o on o.customer_id = c.customer_id
join order_items as oi on oi.order_id = o.order_id
group by c.customer_id
The scan of orders produces 24 rows, and the join of
orders to customers still produces 24. The join to
order_items produces 48: one row per order line, not per order.
So count(*) here counts lines: customer 2 placed one order,
with two products in it, and shows 2. That is the bug on
COUNT or SUM too high after a JOIN, visible as a row count that doubles between two boxes.
count(distinct o.order_id) counts orders instead.
EXPLAIN on a change
EXPLAIN works on UPDATE, DELETE, and
INSERT too, and it changes nothing.
EXPLAIN ANALYZE does change data, because it runs the
statement. Try it on a copy:
create or replace table orders_copy as
select * from orders explain
delete from orders_copy
where status = 'cancelled'
The plan is a DELETE box over a scan filtered on
status='cancelled', estimated at ~6 rows.
orders_copy still has all 24 orders.
explain analyze
delete from orders_copy
where status = 'cancelled' select count(*) from orders_copy
23. The one cancelled order is gone: the estimate said six, and
EXPLAIN ANALYZE deleted the one that really matched. On a real
database, wrap an analyzed change in a transaction and roll it back; see
safe UPDATE and DELETE.
Other output formats
explain (format json)
select count(*) from orders
Each step becomes an object with a name, its
children, and an extra_info holding the same
details as the boxes, including Estimated Cardinality. JSON is
easier to search or compare than box drawings when a plan is long.
What can go wrong
- Treating estimates as facts. On the queries above, DuckDB
guessed 4 for 5, 6 for 7, and 6 for 1. An estimate tells you the shape the
planner expected, not what the data holds. Use
EXPLAIN ANALYZEwhen the number matters. - Expecting the plan to mirror the SQL. The planner
rewrites queries. With
order by price desc limit 3, DuckDB 1.5 finds the top three rows by row id first, then joins back to the table to fetch their columns, and estimates~0 rowsfor a step that returns 3. Read what the plan does, not what you expect it to do. - Trusting the timings. Here the engine runs in your browser tab, on data a few dozen rows long, so every step takes a fraction of a millisecond and the times tell you nothing about a production database. Row counts are the useful part.
- Forgetting that ANALYZE executes. On a production
database,
EXPLAIN ANALYZEon a slow query is as slow as the query, and on aDELETEit deletes.
EXPLAIN does still check the query: a misspelled column fails
with the same Binder Error the query would raise, without
running anything. See
Referenced column not found.
Across databases
Every engine has a plan; the command, and whether it runs the query, differ. Operator names differ too, so a plan from one engine does not read the same in another.
| Engine | Plan only | Runs the query |
|---|---|---|
| DuckDB | explain … | explain analyze … |
| PostgreSQL | explain … | explain analyze …, often with buffers |
| MySQL | explain …, or explain format=tree … | explain analyze … (8.0.18 and later) |
| SQL Server | set showplan_xml on, or the estimated plan in SSMS
| set statistics xml on, or the actual plan in SSMS
|
| Oracle | explain plan for …, then
dbms_xplan.display | dbms_xplan.display_cursor after running it |
| SQLite | explain query plan … |
No SQL command; plain explain shows bytecode instead
|
On a production database, plans are mostly read to find slow queries: a full scan where an index would do, or a join order that builds a huge intermediate result. On SQLShed's small datasets nothing is slow, so the plan is more useful as a debugging tool: it shows which rows each step keeps, where a filter is applied, and where a join multiplies rows. For a step-by-step way to narrow down a wrong result, see debugging in small steps.
Related
One-to-many joins can inflate totals.
Indentation, casing, and aliasing conventions.
CREATE INDEX for uniqueness, and when an index is used at all.
NOT NULL, DEFAULT, CHECK, UNIQUE, and keys.