Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Aggregates

GROUP BY folds rows into one per group; the aggregate functions are listed under Queries. End to end:

SELECT region,
       COUNT(*)                AS orders,
       COUNT(DISTINCT customer) AS customers,
       SUM(amount)             AS revenue
FROM 'orders.parquet'
WHERE placed_at >= '2026-01-01'
GROUP BY region
HAVING COUNT(*) > 100
ORDER BY revenue DESC
LIMIT 10;

A plan-time constant may sit in the list alongside the aggregates, since it is one value for the whole query — a literal, arithmetic or || over literals, a call like now(), or a $param / $let. That is how an aggregate result carries a run id or a tenant tag:

PARAM tag STRING DEFAULT 'acme';
LET run_ts = now();
SELECT $tag AS tenant, $run_ts AS loaded_at, region, COUNT(*) AS orders
FROM 'orders.parquet' GROUP BY region;

A plain column still may not: it has no single value per group, so it must be wrapped in an aggregate or named in GROUP BY.

FILTER

Any aggregate takes FILTER (WHERE <condition>), folding only the rows where the condition holds — several slices of a group in one pass, where a WHERE would need a query each:

SELECT region,
       COUNT(*)                                       AS orders,
       COUNT(*)    FILTER (WHERE status = 'returned') AS returns,
       SUM(amount) FILTER (WHERE amount > 0)          AS revenue,
       COUNT(DISTINCT customer) FILTER (WHERE placed_at >= '2026-01-01') AS new_year_customers
FROM 'orders.parquet'
GROUP BY region
HAVING SUM(amount) FILTER (WHERE amount > 0) > 1000;

A null condition counts as false. Every aggregate skips nulls, so agg(x) FILTER (WHERE c) is read as agg(if(c, x, null)) and COUNT(*) FILTER (WHERE c) as count_if(c): the result is the same, and a filtered aggregate runs wherever a plain one does — on the parallel lanes, spilling, and over a window. An aggregate with no matching row is null (COUNT 0).

A float SUM is reproducible for a given -j, not across values of it. The lanes each total their own slice and the slices are added in a fixed order, so rerunning the same command writes the same bytes; but -j decides how the input is cut, and float addition is not associative, so -j 4 and -j 8 can differ in the last bits (as can either from -j 1). SUM over an INT or a DECIMAL is exact and identical everywhere — SUM(CAST(amount AS DECIMAL(18,2))) is the way to total money you intend to compare or checksum. COUNT, MIN and MAX are exact too, as are count_if, bool_and/bool_or and the bit_* aggregates. The variances and deviations combine their lanes the same fixed way, so they share SUM’s float caveat: ROUND them before comparing runs at different -j.

Past --op-memory, a GROUP BY spills to disk (see Spilling to disk): groups it already holds keep folding in memory, and rows of new groups go to disk by a hash of their key, to be aggregated once the input ends. Every result is exact — a float SUM included, since each group still sees its rows in input order — but the groups come out in another order, so add ORDER BY if the order matters. The disk it may use is bounded by --spill-cap. Spilling happens on a -j 1 run and on any aggregate the parallel lanes do not take; the lanes’ own per-lane tables do not spill.