GROUPING SETS, ROLLUP, and CUBE
GROUPING SETS, ROLLUP, and CUBE compute several levels of aggregation in
one query, such as detail rows, subtotals, and a grand total. QuestDB computes
every level in a single scan of the data, instead of running a separate
GROUP BY per level and combining the results with UNION ALL.
Syntax
Grouping sets can be used with both GROUP BY and SAMPLE BY.
SELECT column [, ...], aggregate(column) [, ...]
FROM table
[WHERE condition]
GROUP BY [column [, ...],] { ROLLUP | CUBE } (column [, ...])
SELECT column [, ...], aggregate(column) [, ...]
FROM table
[WHERE condition]
GROUP BY GROUPING SETS ({ (column [, ...]) | () } [, ...])
SELECT timestamp, [column [, ...],] aggregate(column) [, ...]
FROM table
[WHERE condition]
SAMPLE BY [count]unit
{ ROLLUP(column [, ...])
| CUBE(column [, ...])
| GROUPING SETS ({ (column [, ...]) | () } [, ...]) }
[FROM timestamp TO timestamp]
[FILL(...)]
[ALIGN TO CALENDAR [TIME ZONE tz] [WITH OFFSET offset]]
With GROUP BY, plain columns can precede ROLLUP or CUBE, as described in
Composite syntax.
With SAMPLE BY, the grouping clause comes directly after the sample interval,
before FROM ... TO and FILL. See
SAMPLE BY integration for the rules that differ from
a regular SAMPLE BY query.
GROUPING SETS
GROUPING SETS gives explicit control over which grouping combinations to
compute. Each set in the list produces its own group of aggregated rows.
SELECT symbol, side, SUM(amount) AS total_amount, COUNT(*) AS trade_count
FROM trades
WHERE timestamp IN '$now-1m..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
GROUP BY GROUPING SETS (
(symbol, side),
(symbol),
()
);
(symbol, side)groups by both columns (detail rows)(symbol)groups by symbol only (subtotals per symbol,sideisNULL)()is the empty set, producing a single grand total row (both columnsNULL)
The grand total row is returned even when no rows match the WHERE clause. In
that case COUNT(*) returns 0 and SUM() returns NULL.
You can specify any combination of column subsets. ROLLUP and CUBE are
shorthand for common GROUPING SETS patterns.
The list must contain at least one set, with no trailing comma.
GROUPING SETS () is a syntax error; for a grand total only, write
GROUPING SETS (()).
Every set is written in parentheses, including one-column sets:
GROUPING SETS ((symbol), (side), ()), not GROUPING SETS (symbol, side, ()).
QuestDB does not remove duplicate grouping sets. Listing the same set twice,
as in GROUPING SETS ((symbol), (symbol)), returns its rows twice.
ROLLUP
ROLLUP generates hierarchical subtotals, progressively dropping columns from
right to left. With N columns, ROLLUP produces N+1 grouping sets.
SELECT symbol, side,
SUM(price * amount) AS volume,
COUNT(*) AS trades
FROM trades
WHERE timestamp IN '$now-1m..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
GROUP BY ROLLUP(symbol, side)
ORDER BY symbol, side;
This produces:
- Per-symbol, per-side detail rows
- Per-symbol subtotals (
sideisNULL) - A single grand total row (both
NULL)
ROLLUP(symbol, side) is equivalent to:
GROUP BY GROUPING SETS (
(symbol, side),
(symbol),
()
)
With three columns, ROLLUP(a, b, c) produces four grouping sets:
GROUP BY GROUPING SETS (
(a, b, c),
(a, b),
(a),
()
)
CUBE
CUBE generates all possible combinations of the specified columns. With N
columns, CUBE produces 2^N grouping sets.
SELECT symbol, side,
SUM(amount) AS total_amount,
GROUPING_ID(symbol, side) AS grp
FROM trades
WHERE timestamp IN '$now-1m..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
GROUP BY CUBE(symbol, side)
ORDER BY grp, symbol, side;
CUBE(symbol, side) is equivalent to:
GROUP BY GROUPING SETS (
(symbol, side), -- both grouped
(symbol), -- symbol only
(side), -- side only
() -- grand total
)
Ordering by GROUPING_ID groups the output by aggregation level:
grp=0: all detail combinationsgrp=1: per-symbol totals (side rolled up)grp=2: per-side totals (symbol rolled up)grp=3: grand total
CUBE accepts at most 15 columns. The number of grouping sets is also capped
by cairo.sql.max.grouping.sets, which defaults to 4096. With the default,
CUBE accepts at most 12 columns (2^12 = 4,096 sets). Using 13 to 15 columns
requires raising that property.
Composite syntax
Plain GROUP BY columns can be combined with ROLLUP or CUBE. The plain
columns are always included in every grouping set.
SELECT symbol, side, SUM(amount) AS total_amount
FROM trades
WHERE timestamp IN '$now-1m..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
GROUP BY symbol, ROLLUP(side);
This is equivalent to:
GROUP BY GROUPING SETS (
(symbol, side),
(symbol)
)
There is no empty set () here because symbol is always present.
Plain columns must come before ROLLUP or CUBE. The following forms are
rejected:
- Columns after the construct:
GROUP BY ROLLUP(side), symbol - More than one construct:
GROUP BY ROLLUP(symbol), CUBE(side) - Plain columns with
GROUPING SETS:GROUP BY symbol, GROUPING SETS (...)
For any of these, write out the sets you need with GROUPING SETS. For
example, GROUP BY symbol, GROUPING SETS ((side), ()) is written as
GROUP BY GROUPING SETS ((symbol, side), (symbol)).
Expressions as keys
ROLLUP, CUBE, and GROUPING SETS accept column references only. An
expression written inside them, such as ROLLUP(a + b), is rejected even when
every set contains it. A SELECT alias of an expression is accepted only when
every grouping set contains it. Plain keys before ROLLUP or CUBE in
composite syntax can be expressions.
To roll up an expression, compute it in a subquery and group by its output column:
SELECT symbol, price_level, SUM(amount) AS total_amount
FROM (
SELECT symbol, round(price, -2) AS price_level, amount
FROM trades
WHERE timestamp IN '$now-1h..$now'
AND symbol = 'BTC-USDT'
)
GROUP BY ROLLUP(symbol, price_level);
GROUPING() and GROUPING_ID() functions
When columns are rolled up, they appear as NULL in the result. The data might
also contain genuine NULL values. GROUPING() and GROUPING_ID() distinguish
between the two.
GROUPING(column)
Accepts a single column. Returns:
0if the column is actively grouped (aNULLis a real data value)1if the column is rolled up (theNULLis a placeholder)
SELECT symbol, side, SUM(amount) AS total_amount,
GROUPING(symbol) AS gs,
GROUPING(side) AS gsd
FROM trades
WHERE timestamp IN '$now-1m..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
GROUP BY ROLLUP(symbol, side)
ORDER BY gs, gsd, symbol, side;
In the results:
| gs | gsd | Meaning |
|---|---|---|
| 0 | 0 | Detail row: both columns actively grouped |
| 0 | 1 | Subtotal: grouped by symbol, side rolled up |
| 1 | 1 | Grand total: both columns rolled up |
GROUPING_ID(column1, column2, ...)
Accepts one to 31 columns. Returns an integer bitmask combining the
GROUPING() values of all specified columns. Bit positions are assigned
right-to-left: the rightmost argument occupies bit 0 (least significant bit).
SELECT symbol, side, SUM(amount) AS total_amount,
GROUPING_ID(symbol, side) AS grp
FROM trades
WHERE timestamp IN '$now-1m..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
GROUP BY CUBE(symbol, side)
ORDER BY grp, symbol, side;
For GROUPING_ID(symbol, side), bit 1 is assigned to symbol and bit 0 to
side:
| grp | Binary | Meaning |
|---|---|---|
| 0 | 0b00 | Both columns grouped |
| 1 | 0b01 | side rolled up |
| 2 | 0b10 | symbol rolled up |
| 3 | 0b11 | Both rolled up (grand total) |
Writing GROUPING_ID(side, symbol) would reverse the bit assignments.
SAMPLE BY integration
Grouping sets work with QuestDB's SAMPLE BY clause for time-bucketed
aggregation with multiple rollup levels.
SELECT timestamp, symbol, SUM(amount) AS total_amount, AVG(price) AS avg_price
FROM trades
WHERE timestamp IN '$now-1d..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
SAMPLE BY 1h ROLLUP(symbol)
ORDER BY timestamp, symbol;
Each time bucket contains one row per symbol plus one grand total row (where
symbol is NULL). The timestamp column is never rolled up. It is always
present as the time bucket key. A bucket with no input rows produces no rows,
including no grand total row. FILL can add rows for such buckets, as
described in FILL support.
In a regular SAMPLE BY query, every non-aggregated column in the SELECT
list becomes a key. With grouping sets, this does not happen: every
non-aggregated column other than the timestamp must appear in the ROLLUP,
CUBE, or GROUPING SETS clause. SAMPLE BY also does not support
composite syntax. To keep a column grouped at every level,
list it in each set:
SELECT timestamp, symbol, side, SUM(amount) AS total_amount
FROM trades
WHERE timestamp IN '$now-1d..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
SAMPLE BY 1h GROUPING SETS ((symbol, side), (symbol))
ORDER BY timestamp, symbol, side;
The input can be a table, or a subquery or CTE that keeps the designated timestamp:
WITH btc AS (
SELECT * FROM trades
WHERE timestamp IN '$now-1d..$now'
AND symbol = 'BTC-USDT'
)
SELECT timestamp, side, SUM(amount) AS total_amount
FROM btc
SAMPLE BY 1h ROLLUP(side)
ORDER BY timestamp, side;
ALIGN TO FIRST OBSERVATION is not supported with grouping sets. The sample
interval must be a constant, so SAMPLE BY $1 h is rejected. The FROM bound
of FROM ... TO must be a timestamp literal; functions, expressions, and bind
variables are not supported there.
FILL support
FILL works with grouping sets. Missing time buckets are filled per key
combination: each distinct (symbol, grouping level) pair gets its own fill row.
FILL fills the key combinations that the input produced. One matching row is
enough to fill its series, including the grand total, across the whole range.
When no input rows match, there is nothing to fill and the query returns no
rows, even with FROM ... TO and even for the grand total of ROLLUP or
CUBE. Plain keyed SAMPLE BY ... FILL behaves the same way. This differs from
GROUP BY, where the empty set () returns a grand total row on empty input.
SELECT timestamp, symbol, SUM(amount) AS total_amount, AVG(price) AS avg_price
FROM trades
WHERE timestamp IN '$now-1d..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
SAMPLE BY 1h ROLLUP(symbol) FILL(0)
ORDER BY timestamp, symbol;
Supported FILL modes:
| FILL mode | Supported |
|---|---|
FILL(NONE) | Yes |
FILL(NULL) | Yes |
FILL(value) | Yes |
FILL(PREV) | No |
FILL(LINEAR) | No |
FILL(NULL) and FILL(value) require distinct grouping sets. Queries that
repeat a set, such as GROUPING SETS ((symbol), (symbol)) or
ROLLUP(symbol, symbol), are rejected. Two sets count as duplicates when they
contain the same columns, in any order or through an alias, so
GROUPING SETS ((symbol, side), (side, symbol)) is rejected too. FILL(NONE)
accepts duplicate sets.
FILL(NULL) and FILL(value) also support at most 31 rolled-up keys. A key is
rolled up when at least one grouping set leaves it out, so ROLLUP(a, b, c)
rolls up all three keys. A plain key before ROLLUP or CUBE in
composite syntax is in every set and does not count.
GROUPING() and GROUPING_ID() values are preserved in fill rows. They are not
replaced by the fill value.
SELECT GROUPING(symbol) AS gs, timestamp, symbol, SUM(amount) AS total_amount
FROM trades
WHERE timestamp IN '$now-1d..$now'
AND symbol IN ('BTC-USDT', 'ETH-USDT')
SAMPLE BY 1h ROLLUP(symbol) FILL(NULL)
ORDER BY timestamp, gs, symbol;
A fill row for a missing hour shows gs=0 for detail-level fills and gs=1 for
grand-total-level fills, just like real data rows. Only aggregate columns get the
fill value.
Execution
QuestDB computes all grouping sets in a single scan of the input. When
parallel GROUP BY
is enabled, the query runs on multiple worker threads, and EXPLAIN shows an
Async Group By node with groupingSets: true, or Async JIT Group By when
the WHERE filter is JIT-compiled. Queries with many grouping sets relative to
the number of query workers run on a single thread.
Grouping sets aggregation is never vectorized. When you only need one level of
aggregation, a plain GROUP BY over the same keys can be faster.
When each grouping set on its own would run as a vectorized GROUP BY,
typically a single key with simple aggregates such as sum(), a UNION ALL
of one plain GROUP BY per set can be several times faster with parallel
execution. Run EXPLAIN on the plain GROUP BY: vectorized: true means it
takes the faster path. With two or more keys the difference is much smaller.
SELECT symbol, SUM(amount) AS total_amount
FROM trades
WHERE timestamp IN '$now-1d..$now'
GROUP BY ROLLUP(symbol);
SELECT symbol, SUM(amount) AS total_amount
FROM trades
WHERE timestamp IN '$now-1d..$now'
GROUP BY symbol
UNION ALL
SELECT NULL::SYMBOL, SUM(amount)
FROM trades
WHERE timestamp IN '$now-1d..$now';
Rows are not returned in any guaranteed order. Use ORDER BY when order
matters.
Limitations
-
Expressions not allowed in
ROLLUP,CUBE, orGROUPING SETS. Compute them in a subquery instead. See Expressions as keys. -
No mixed qualified/unqualified references to the same column.
ROLLUP(a, t.a)is rejected. Use one form consistently. -
Parentheses required around each set in
GROUPING SETS. Write(symbol), notsymbol. -
One
ROLLUPorCUBEperGROUP BY, placed last.GROUP BY ROLLUP(a), CUBE(b)andGROUP BY ROLLUP(a), bare rejected, andGROUPING SETScannot be combined with plain columns. See Composite syntax. -
Not supported with
LATEST ON. Rejected with an error. -
Not supported with
HORIZON JOIN. Rejected with an error. -
Not supported in correlated
LATERALsubqueries. ALATERALsubquery that references the outer table cannot use grouping sets. UncorrelatedLATERALsubqueries can. -
Not supported in materialized views. A materialized view query cannot use
GROUPING SETS,ROLLUP, orCUBE. -
FILL(PREV)andFILL(LINEAR)not supported with grouping sets. -
ALIGN TO FIRST OBSERVATIONnot supported with grouping sets inSAMPLE BY. -
SAMPLE BYinterval must be a constant. A bind variable such asSAMPLE BY $1 his rejected with grouping sets. -
FILL(NULL)andFILL(value)require distinct grouping sets and support at most 31 rolled-up keys inSAMPLE BY. See FILL support. -
SAMPLE BY ... FILLreturns no rows on empty input, not even a grand total, because there is no key combination to fill. See FILL support. -
SAMPLE BY ... FROMrequires a timestamp literal. Functions, expressions, and bind variables in theFROMbound are not supported with grouping sets. -
GROUPING_ID()accepts at most 31 arguments, because its result is anINTbitmask.GROUPING()and the number ofGROUP BYkeys have no such limit. -
Maximum grouping sets per query, controlled by the
cairo.sql.max.grouping.setsconfiguration property (default 4096).ROLLUPproduces N+1 sets,CUBEproduces 2^N sets, and explicitGROUPING SETSproduces one set per listed group. Queries exceeding this limit are rejected at parse time.CUBEalso has a hard limit of 15 columns, so with the default it accepts at most 12.
See also
- GROUP BY - Standard grouping
- SAMPLE BY - Time-series aggregation
- PIVOT - Transform GROUP BY results from rows to columns
- Aggregation functions - Available aggregate functions