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.

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.

idregionstatus
1Westpaid
2Northpaid
3Southpaid
4Eastpaid
5Westcancelled
6Northpaid
7Southpaid
8Eastpaid

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.

idregioncampaign
1EastSpring
2EastLoyalty
3WestSpring
4WestLoyalty
5NorthSpring
6NorthLoyalty
7SouthSpring
8SouthLoyalty

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';
Captured execution profile · selected operators
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';
Expected result · sample data1 row
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);
Expected result · sample data1 row
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);
Captured execution profile · selected operators
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
Your target result4 rows
region qualifying_orders
East 2000
North 2000
South 2000
West 2000
Sample data is loaded for you.

Preparing the editor…

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.