Conditional aggregation

Ask the table once, and let every row answer several questions on its way past.

The idea

A filter throws rows away. A CASE inside an aggregate does something gentler: it lets every row through, but turns it into a 1 or a 0 for each measure you care about.

So one column becomes “how many shipped”, another “how many pending”, another “how many cancelled”: all from the same sweep of the same rows. That is a pivot, without any pivot feature.

Rates fall out of the same trick: count the matches on top, count everything underneath. The alternative is running one filtered query per measure, which reads the whole table again each time for no new information.

One sweep, three counters

orders 9 when status = 'shipped' +1 0 shipped when status = 'pending' +1 0 pending when status = 'cancelled' +1 0 cancelled #0 - #0 - #0 -

Ready: press play and watch one row do three jobs.

step0 / 9
rows read0
table reads0
cancelled rate = SUM(CASE WHEN … THEN 1 ELSE 0 END) / COUNT(*)
0 0 = -
mode
rate for
9 rows

In single pass every row is evaluated by all three gates at once. In three filtered scans only one gate is live per scan, so the same table is dragged past three times, and the rate’s denominator still needs a fourth.

How it works

Inside an aggregate, CASE maps each row to a number: 1 when the condition holds, 0 when it does not. SUM then adds those numbers up, which is exactly a conditional count. Because all the accumulators live in the same aggregation, the engine fills them during one scan, and a GROUP BY turns the result into a pivot table: one row per group, one column per condition.

-- one sweep, five measures, one table read
SELECT
  region,
  COUNT(*)                                                    AS orders,
  SUM(CASE WHEN status = 'shipped'   THEN 1 ELSE 0 END)       AS shipped,
  SUM(CASE WHEN status = 'pending'   THEN 1 ELSE 0 END)       AS pending,
  SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END)       AS cancelled,
  ROUND(100.0 * SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END)
              / COUNT(*), 1)                                  AS cancel_pct
FROM orders
WHERE ordered_at >= DATE '2024-01-01'   -- rows no measure wants
GROUP BY region;

-- money works the same way; use no ELSE when averaging,
-- so non-matching rows drop out instead of averaging in as zero
SELECT
  SUM(CASE WHEN status = 'shipped' THEN amount ELSE 0 END) AS shipped_revenue,
  AVG(CASE WHEN status = 'shipped' THEN amount END)        AS avg_shipped_order
FROM orders;

-- postgres/sqlite shorthand for the counting case
SELECT COUNT(*) FILTER (WHERE status = 'shipped') AS shipped FROM orders;

Cost

For n rows and k measures, with g groups:

aspectconditional aggregationone query per measure
table reads1k
rows scannednk · n
predicate evaluationsk · nk · n
extra memoryO(g · k)O(g) per query, k round trips

The CPU work is the same either way; what you save is I/O and round trips. The trade-off: conditional aggregation always pays for a full pass, so if one measure is very selective and indexed (status = 'cancelled' on 0.1% of rows), a narrow index scan can beat it. The sweet spot is several measures over the same broad set of rows.

Watch out for

Worked example

Take the nine orders in the widget above: five shipped, two pending, two cancelled. A single scan carries every row past all three CASE gates, so after nine reads the accumulators hold 5, 2 and 2, and COUNT(*) holds 9: the cancel rate is 2 / 9 = 22.2%, assembled from the same pass, not a second query.

Now scale it: 1.2 million orders, and the dashboard wants those three counts plus the cancel rate per region. Written as four filtered queries it is four full scans and four round trips. Written as one SELECT … GROUP BY region with four conditional aggregates, the planner reads the table once, keeps four small counters per region in the hash aggregate, and hands back a finished pivot (region, shipped, pending, cancelled, cancel_pct) ready to render.

Check yourself

Which expression gives the fraction of all rows where status = 'cancelled'?

You keep all three CASE columns but add WHERE status = 'shipped'. What happens?