The final result is one row. The join produced 16,000. That is not necessarily a problem—aggregates are meant to reduce rows—but here it exposes a mistake: every eligible order was counted twice.
EXPLAIN ANALYZE lets us follow the rows actually produced during execution. We will use that evidence to investigate a query, repair its meaning, and measure it again.
Explore the idea
One output row can hide a lot of intermediate work
Inspect the measured counts before and after the eligibility repair.
8,000
actual scan output rows
The filtered orders scan produces 8,000 rows
There are 10,000 stored orders. The paid filter passes 8,000 to the next operator.
16,000
actual inner-join output rows
Two campaigns turn each order into two pairs
The final COUNT returns one row containing 16,000. It counted order–campaign pairs, not eligible orders.
8,000
actual semi-join output rows
Eligibility retains each order once
In the captured build, EXISTS becomes a semi join. Its output count matches the intended one-record-per-order question.
The business question comes first
We want the number of paid orders whose region has at least one promotional campaign. An order qualifies once, however many campaigns its region has.
The fixture contains 10,000 orders, of which 8,000 are paid. Each of the four regions has two campaigns. The expected count is therefore 8,000.
orders10,000 rows
Orders to test for eligibility
First eight of 10,000 generated orders. Exactly 8,000 are paid.
| id | region | status |
|---|---|---|
| 1 | West | paid |
| 2 | North | paid |
| 3 | South | paid |
| 4 | East | paid |
| 5 | West | cancelled |
| 6 | North | paid |
| 7 | South | paid |
| 8 | East | paid |
promotions8 rows
Two campaigns in each region
An order qualifies if its region has any campaign. A second campaign should not count the order again.
| id | region | campaign |
|---|---|---|
| 1 | East | Spring |
| 2 | East | Loyalty |
| 3 | West | Spring |
| 4 | West | Loyalty |
| 5 | North | Spring |
| 6 | North | Loyalty |
| 7 | South | Spring |
| 8 | South | Loyalty |
If you are new to plan operators, first read the short EXPLAIN tutorial. It explains scans, filters, and the difference between an estimate and an observation.
Execute the query with measurement
EXPLAIN ANALYZE executes the query and reports its plan with runtime information. Run this SELECT-only example and inspect its actual row counts:
Run and profile the query with the duplicated matches
EXPLAIN ANALYZE
SELECT count(*) AS qualifying_orders
FROM orders o JOIN promotions p ON o.region = p.region
WHERE o.status = 'paid';
Recorded query total time: 0.566 ms UNGROUPED_AGGREGATE Aggregates: count_star() Estimated Cardinality: 1 Actual output rows: 1 Operator time: 0.001 ms HASH_JOIN Join Type: INNER Conditions: region = region Estimated Cardinality: 10000 Actual output rows: 16,000 Operator time: 0.119 ms TABLE_SCAN Table: memory.main.orders Type: Sequential Scan Filters: (status = 'paid') Estimated Cardinality: 5000 Actual output rows: 8,000 Operator time: 0.132 ms TABLE_SCAN Table: memory.main.promotions Type: Sequential Scan Estimated Cardinality: 8 Actual output rows: 8 Operator time: 0.002 ms
Captured with native DuckDB 2.0.0 on these fixtures. Projection/internal operators omitted; browser formatting can differ. Timings are one recorded run, not a benchmark.
The excerpt preserves selected operators and timings from one native DuckDB 2.0.0 run. Your browser’s values will differ. The full captured JSON profiles are available for inspection.
Because the statement runs the query, its data access and computation really happen. On your own workload, choose a representative query you are ready to execute; do not treat ANALYZE as a free preview.
Follow the actual row counts
The orders scan outputs 8,000 paid rows. The promotions scan outputs eight campaign rows. The inner join outputs 16,000 matching pairs because each order’s region matches two campaigns. The final aggregate reduces those pairs to one count.
These are operator output counts, not interchangeable measures of stored rows, rows inspected, or network bytes. The full profile separately records scan details. A scan that returns 8,000 filtered rows may have examined more input rows to find them.
The captured join estimate is 10,000, but its actual output is 16,000. Comparing estimates with observations can identify where the planner’s expectations diverge. Here, inspecting the join’s meaning reveals the more urgent issue: we are counting order–campaign pairs.
See the query’s ordinary result:
See what that query actually counts
SELECT count(*) AS qualifying_orders
FROM orders o JOIN promotions p ON o.region = p.region
WHERE o.status = 'paid';
| qualifying_orders |
|---|
| 16000 |
The single output row conceals the multiplication that produced its value. The measured plan makes that expansion visible.
Express eligibility instead of returning every match
We only need to know whether a campaign exists for the order’s region. An EXISTS condition expresses that question:
Express eligibility with EXISTS
SELECT count(*) AS qualifying_orders
FROM orders o
WHERE o.status = 'paid'
AND EXISTS (SELECT 1 FROM promotions p WHERE p.region = o.region);
| qualifying_orders |
|---|
| 8000 |
Now the count is 8,000. Profile this version too:
Profile the corrected eligibility query
EXPLAIN ANALYZE
SELECT count(*) AS qualifying_orders
FROM orders o
WHERE o.status = 'paid'
AND EXISTS (SELECT 1 FROM promotions p WHERE p.region = o.region);
Recorded query total time: 0.496 ms UNGROUPED_AGGREGATE Aggregates: count_star() Estimated Cardinality: 1 Actual output rows: 1 Operator time: 0.001 ms HASH_JOIN Join Type: SEMI Conditions: region = region Estimated Cardinality: 1000 Actual output rows: 8,000 Operator time: 0.062 ms TABLE_SCAN Table: memory.main.orders Type: Sequential Scan Filters: (status = 'paid') Estimated Cardinality: 5000 Actual output rows: 8,000 Operator time: 0.130 ms TABLE_SCAN Table: memory.main.promotions Type: Sequential Scan Estimated Cardinality: 8 Actual output rows: 8 Operator time: 0.001 ms
Captured with native DuckDB 2.0.0 on these fixtures. Projection/internal operators omitted; browser formatting can differ. Timings are one recorded run, not a benchmark.
In the captured build, DuckDB uses a semi join. It outputs each qualifying order once, leaving 8,000 rows for the aggregate. The exact plan may evolve, but the result and the intended one-count-per-order rule remain the checks that matter.
The rewrite deliberately changes the wrong answer. This is a correctness repair, not an equivalent-query speed comparison. The join multiplication tutorial explores the same mistake on five visible item rows.
Interpret timings with care
Operator timings help locate expensive work, but adding them is not a reliable way to reconstruct total elapsed time. Parallel execution and the way operator work is recorded matter. Use the reported query total as its own measure, and inspect the execution environment before interpreting a timing difference.
This tiny fixture is useful for row counts, not a performance benchmark. One cached run may differ from a cold run; profiling and browser startup have costs of their own. For a real optimization, keep input data and environment comparable, verify equivalent results, and repeat representative measurements. Record the engine and extension versions.
Your turn · check your answer
Count eligible orders by region
Return region and qualifying_orders for paid orders with at least one regional campaign. Count each order once.
See the expected result
| region | qualifying_orders |
|---|---|
| East | 2000 |
| North | 2000 |
| South | 2000 |
| West | 2000 |
Preparing the editor…
Your result
Need a hint?
Use EXISTS or a semijoin for eligibility. Group the order records, not repeated campaign matches.
Show a solution
SELECT o.region, count(*) AS qualifying_orders
FROM orders o
WHERE o.status = 'paid'
AND EXISTS (SELECT 1 FROM promotions p WHERE p.region = o.region)
GROUP BY o.region ORDER BY o.region;When a query is slow, work outward from the evidence: an unexpectedly expanding join, a large intermediate sort, a scan with few useful filters, or work happening on the wrong side of a remote connection. Use Parquet metadata or remote aggregate diagnostics when those are the relevant next questions.