Pre-Aggregating Rollups with AggregatingMergeTree
AggregatingMergeTree maintains rolling aggregates — distinct counts, quantiles, averages, sums — by storing partial aggregate state per key and combining that state whenever a background merge runs, so a dashboard reads a pre-computed rollup instead of scanning raw events. This walkthrough builds an aggregate target table with AggregateFunction columns, feeds it on the insert path with a materialized view that emits -State values, finalizes reads with the matching -Merge combinators, and verifies the state pipeline against system.parts and the raw source.
Prerequisites
The -State / -Merge pipeline
The whole design rests on one idea: an aggregate can be split into a partial state that is cheap to store and mergeable with other states of the same aggregate. A -State combinator (uniqState, sumState, quantileState) produces that intermediate state instead of a final number; the AggregatingMergeTree combines states with the same key on every merge; and a -Merge combinator (uniqMerge, sumMerge, quantileMerge) finalizes the state into a value at read time. The raw events are aggregated once, on the insert path, and never scanned again.
Step 1 — Create the raw source table
The source is an ordinary MergeTree of individual events. It carries the raw dimensions and measures the rollup will aggregate.
CREATE TABLE analytics.events_raw
(
`event_ts` DateTime64(3),
`country` LowCardinality(String),
`user_id` UInt64,
`latency_ms` Float64,
`amount` Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_ts)
ORDER BY (country, event_ts);
Expected: a plain source table. Nothing aggregates yet — this is only the write target for producers.
Step 2 — Create the AggregatingMergeTree target
The target’s measure columns are typed AggregateFunction(fn, argtypes) — they hold state, not values. Key the target on the rollup dimensions; that ORDER BY is what the engine uses to merge states per group.
CREATE TABLE analytics.events_rollup
(
`day` Date,
`country` LowCardinality(String),
`visitors` AggregateFunction(uniq, UInt64),
`p95_ms` AggregateFunction(quantile(0.95), Float64),
`revenue` AggregateFunction(sum, Decimal(18, 2))
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, country);
Expected: the target registers with engine = AggregatingMergeTree, and visitors reports type AggregateFunction(uniq, UInt64). It is intentionally unreadable as-is; reads must finalize it.
Step 3 — Attach a materialized view that emits state
The view runs on every insert into events_raw, transforms rows into partial states with -State combinators, and writes them to the target with TO. Its GROUP BY must match the target’s ORDER BY exactly.
CREATE MATERIALIZED VIEW analytics.events_rollup_mv
TO analytics.events_rollup
AS
SELECT
toDate(event_ts) AS day,
country,
uniqState(user_id) AS visitors,
quantileState(0.95)(latency_ms) AS p95_ms,
sumState(amount) AS revenue
FROM analytics.events_raw
GROUP BY day, country;
Expected: the view is created and armed. From this point every insert into the source produces one state row per (day, country) group in that insert block. The view-authoring conventions — column alignment, POPULATE cautions, backfill order — are covered in materialized view creation patterns.
Step 4 — Insert raw events and let the view fire
Insert a batch into the source. The view intercepts the insert path; the target receives state automatically.
INSERT INTO analytics.events_raw VALUES
(now64(3), 'US', 1, 120.0, 9.99),
(now64(3), 'US', 2, 240.0, 4.50),
(now64(3), 'US', 1, 90.0, 9.99), -- same user again; uniq must not double-count
(now64(3), 'DE', 3, 310.0, 19.00);
Expected: events_rollup now holds state rows for (today, 'US') and (today, 'DE'). Do not SELECT * the target expecting numbers — the columns are binary state.
Step 5 — Query with -Merge combinators
Finalize the stored state with the -Merge function matching each column’s aggregate, grouped by the same dimensions. This collapses any un-merged state rows and returns final values.
SELECT
day,
country,
uniqMerge(visitors) AS visitors,
quantileMerge(0.95)(p95_ms) AS p95_ms,
sumMerge(revenue) AS revenue
FROM analytics.events_rollup
GROUP BY day, country
ORDER BY day, country;
Expected: US shows visitors = 2 (user 1 counted once despite two events), a finalized p95 latency, and summed revenue; DE shows visitors = 1. The GROUP BY is mandatory even when the target looks fully merged, because fresh inserts may have added state rows that a background merge has not yet combined.
Verification
Cross-check the rollup against the raw source to prove the aggregation is faithful. The two distinct-user counts must agree:
SELECT
(SELECT uniqMerge(visitors) FROM analytics.events_rollup) AS rollup_visitors,
(SELECT uniq(user_id) FROM analytics.events_raw) AS raw_visitors;
Confirm the target is accumulating state rows and that merges are combining them — a growing part count with stable row counts per key is healthy:
SELECT partition, count() AS parts, sum(rows) AS state_rows
FROM system.parts
WHERE database = 'analytics' AND table = 'events_rollup' AND active
GROUP BY partition
ORDER BY partition;
Check that the view is registered against the intended source and target, a common place for a silent misconfiguration:
SELECT name, engine, as_select
FROM system.tables
WHERE database = 'analytics' AND name = 'events_rollup_mv';
Gotchas and edge cases
- A
-State/-Mergemismatch returns wrong or unreadable results. Each column must be read with the exact combinator of its aggregate, including parameters: aquantileState(0.95)column is finalized withquantileMerge(0.95), notquantileMerge(0.5). Mismatched parameters silently return a different quantile. - The view
GROUP BYmust equal the targetORDER BY. If the view groups by(day, country)but the target orders by(country, day)— or omits a column — states for the “same” logical key land under different keys and never collapse, inflating storage and row counts. - Reading the target without
-Mergereturns binary state.SELECT visitors FROM events_rollupyields the internalAggregateFunctionblob, not a number. Always finalize, and alwaysGROUP BYthe key even on a freshly optimized table. POPULATEcan miss rows. Creating the view withPOPULATEbackfills at creation time but drops rows inserted during the backfill. Prefer creating the view first, then backfilling history with a separateINSERT INTO events_rollup SELECT … -State … FROM events_raw.SimpleAggregateFunctionis cheaper when the aggregate allows it. For aggregates whose partial and final state are the same type (sum,min,max,any),SimpleAggregateFunction(sum, …)stores a plain value and reads without-Merge, saving space and query complexity — reserve fullAggregateFunctionforuniq,quantile, and other stateful aggregates that genuinely need it.
Related
- MergeTree engine variants — where AggregatingMergeTree fits in the family and how its collapse rule works.
- ReplacingMergeTree vs AggregatingMergeTree vs SummingMergeTree — when to pick aggregate state over a plain sum or a dedup.
- Materialized view creation patterns — how to write and deploy the view that feeds the target.