Learning Objectives
- Evaluate whether pre-aggregating via SummingMergeTree or AggregatingMergeTree is worth its complexity cost against querying raw data with GROUP BY, given a workload's query volume and data growth
- Choose between SummingMergeTree, SimpleAggregateFunction, and full AggregateFunction/AggregatingMergeTree based on which aggregate functions a workload actually needs
- Explain why both pre-aggregating engines still require query-time re-aggregation (sum()+GROUP BY, or -Merge combinators) despite merge-time pre-reduction, and diagnose what happens when that step is skipped
- Defend a pre-aggregation architecture decision against the real cost it imposes — pipeline complexity, or lost flexibility to ask new questions of already-collapsed data
Why This Matters
You've now seen this course's central trade-off twice. replacingmergetree traded immediate correctness for ingestion simplicity, paid off eventually, at merge time. denormalization-tradeoffs traded write-time duplication for read-time speed. This lesson is the same shape of trade one more time, applied to aggregation: pay the cost of summing or combining rows once, spread across background merges, or pay it fresh on every query against raw data. Getting this one wrong looks different from the other two, though — not a support ticket about duplicate rows, but a team that reaches for AggregatingMergeTree because it sounds like the "real" ClickHouse way to do analytics, builds a materialized-view pipeline to feed it, and only later realizes a plain GROUP BY over raw data would have been fast enough, forever, with none of the operational surface they just built. Judgment here means knowing which cost you're actually facing before you build machinery to avoid it.
The trade-off
Every query that answers "total revenue by region" or "distinct visitors per day" is doing an aggregation — collapsing many rows down to few. That collapsing can happen in exactly one of two places: at query time, scanning and reducing raw rows fresh on every read, or at merge time, letting ClickHouse's background merge process do some of that reduction ahead of time, so a query has fewer rows left to finish reducing. SummingMergeTree and AggregatingMergeTree are ClickHouse's tools for the second option — engines that fold matching-key rows together during a merge instead of leaving that entirely to the query.
Neither engine eliminates query-time work; both shrink it. And both inherit the exact timing caveat replacingmergetree already taught: merges run on ClickHouse's own schedule, not yours, so "how much pre-reduction has happened" is never a guarantee you can query-plan around — it's a moving, eventually-consistent target.
The alternatives, steelmanned
Plain MergeTree, aggregated fresh at query time. The case for doing nothing special: a SELECT sum(revenue), uniqExact(user_id) FROM events WHERE ... GROUP BY region against raw data is always exactly correct, needs zero pipeline machinery beyond the table itself, and can answer any question the data supports — a new metric next quarter is just a new query, not a new table or a redesigned materialized view. For a table that's small enough, or queried rarely enough, that a full aggregation scan finishes well within whatever latency budget matters, this is not a compromise — it's the simplest correct answer, and every lesson in this course that's warned against premature complexity applies here too. The real cost only shows up as data and query volume grow: the work an aggregation query does scales with raw row count forever, with no mechanism to make tomorrow's version of the same query cheaper than today's.
SummingMergeTree — sum, and only sum, done automatically. When a merge combines parts, rows sharing the same sorting key get folded into one, with every numeric, non-key column summed. Nothing about writing to it differs from an ordinary MergeTree table — no special insert-time function calls, no serialized state to reason about — which makes it close to a drop-in swap for a plain MergeTree table whose queries are dominated by sum(). It can even work over a Map column, summing values across matching keys inside the map, which is exactly the trick Cloudflare's pipeline (below) relies on. The real limitation is total: SummingMergeTree sums. It has no mechanism for a distinct count, an average, a quantile, or any aggregate that isn't "add the numbers together" — reach for it on a workload that needs uniq() anywhere and it simply cannot do that column's job, full stop, not slowly or approximately.
AggregatingMergeTree — any aggregate function, at a real complexity cost. Where SummingMergeTree is fixed to one operation, AggregatingMergeTree generalizes to arbitrary aggregate functions by storing, per matching key, a combination of aggregate function states rather than a finished value. That state lives in a column typed AggregateFunction(func, ArgType), populated on insert with a -State combinator (uniqState(user_id)) and read back with the matching -Merge combinator (uniqMerge(visitors)). This is real, honest complexity: a column of AggregateFunction type isn't human-readable data, every write path needs the matching -State call, and every read path needs the matching -Merge call, or the result is meaningless rather than merely imprecise. The payoff is that it can pre-reduce anything — Cloudflare needed this specifically because their unique-visitor counts had no home in SummingMergeTree at all.
There's a real middle ground worth knowing before deciding "full AggregateFunction or nothing": for functions where the intermediate state and the final answer are the same value — min, max, sum, any, anyLast, and a handful of others — SimpleAggregateFunction(func, Type) gets the same merge-time pre-reduction without any -State/-Merge ceremony at all, because there's nothing extra to compute once you have the running min or max. A table can mix SimpleAggregateFunction columns for its min/max/sum-shaped metrics with full AggregateFunction columns only for the genuinely stateful ones like uniq or quantile — the complexity tax is per-column, not all-or-nothing.
CREATE TABLE events
(
event_time DateTime,
event_type LowCardinality(String),
region LowCardinality(String),
user_id UInt64,
revenue Decimal(10, 2)
)
ENGINE = MergeTree ORDER BY (region, event_time);
-- SummingMergeTree: revenue is a plain sum, no state needed
CREATE TABLE revenue_by_region
(
day Date,
region LowCardinality(String),
revenue Decimal(10, 2)
)
ENGINE = SummingMergeTree
ORDER BY (region, day);
CREATE MATERIALIZED VIEW revenue_by_region_mv TO revenue_by_region AS
SELECT toDate(event_time) AS day, region, revenue
FROM events;
-- AggregatingMergeTree: distinct visitors needs real aggregate state
CREATE TABLE visitors_by_region
(
day Date,
region LowCardinality(String),
visitors AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (region, day);
CREATE MATERIALIZED VIEW visitors_by_region_mv TO visitors_by_region AS
SELECT toDate(event_time) AS day, region, uniqState(user_id) AS visitors
FROM events
GROUP BY day, region;
-- Reading each back:
SELECT region, sum(revenue) FROM revenue_by_region
WHERE day = today() GROUP BY region;
SELECT region, uniqMerge(visitors) FROM visitors_by_region
WHERE day = today() GROUP BY region;
Decision framework
The reasoning a senior engineer runs through, in order:
- Is raw-data query-time aggregation actually too slow, at today's volume and tomorrow's projected volume — or does it just feel like the "less proper" option? This is the question the alternatives section's steelman for plain
MergeTreeexists to make you actually answer, not skip. Building a materialized-view pipeline to solve a performance problem you don't have yet is real, avoidable cost. - What aggregate functions does the workload actually need? If everything is a sum,
SummingMergeTreeis the least-complexity option that solves the problem. The instant one metric needsuniq,avg,quantile, or anything stateful,SummingMergeTreeis off the table for that column — no amount of clever schema design routes around this limitation, per Cloudflare's own experience below. - For the functions needing more than SummingMergeTree, do they fall into the
SimpleAggregateFunctionset (min/max/sum/any/anyLast and similar) or do they need real state (uniq/avg/quantile)? Defaulting straight to fullAggregateFunctionfor every column pays the-State/-Mergetax even whereSimpleAggregateFunctionwould have gotten the same pre-reduction for free. - Is the team ready to own the materialized-view plumbing this requires, including getting
-State/-Mergeright on every write and read path? This isn't aCREATE TABLEdecision alone — it's a commitment to a pipeline shape, and a-Mergeforgotten on one query is a silent correctness bug, not a slow query. - Does collapsing to this granularity foreclose a question you'll want to ask later? A rollup keyed by
(region, day)can't answer an hourly question after the fact if raw data wasn't kept. Deciding whether to retain raw data alongside the rollup — as Cloudflare explicitly weighed — is part of this decision, not a detail to defer.
Cold Start — No AI
Your team's events table has grown to roughly 2 billion raw rows. The main dashboard runs, on every page load:
SELECT toDate(event_time) AS day, count() AS orders, uniqExact(user_id) AS buyers
FROM events
WHERE event_type = 'purchase' AND event_time > now() - INTERVAL 7 DAY
GROUP BY day;
It's gotten slow enough that the team is discussing pre-aggregation. Before reading further or asking an AI assistant anything: would SummingMergeTree fix this? Would AggregatingMergeTree? Would neither — and if neither, what would you actually recommend? Write down your reasoning, including which specific part of this query is or isn't a fit for each engine.
Reflection: Where were you tempted to just ask an AI "how do I speed up a slow ClickHouse aggregation query" and take whatever engine name came back first? A generic prompt like that is likely to surface SummingMergeTree or AggregatingMergeTree by name without ever noticing that uniqExact(user_id) — a real, stateful distinct count — cannot be handled by SummingMergeTree at all, only by AggregatingMergeTree (or SimpleAggregateFunction, which also doesn't cover uniq). Would a quick AI answer have caught that count() and uniqExact() need two different pre-aggregation shapes in the same rollup, not one engine picked for the whole query?
Reasoning Prompt
A separate team's events table needs a rollup with three metrics: sum(revenue), max(order_value), and uniqExact(user_id), all grouped by (region, day). They're deciding between (1) one AggregatingMergeTree table using full AggregateFunction state for all three columns, for consistency, and (2) a table mixing types by function — SimpleAggregateFunction for the sum and the max, full AggregateFunction only for the uniq. Reason through which you'd recommend, and what would change your mind. There isn't a single correct answer — multiple choices are defensible depending on assumptions you'll need to state explicitly.
Model Answer⌄
A reasonable default: the mixed table — SimpleAggregateFunction for sum(revenue) and max(order_value), full AggregateFunction only for the uniqExact(user_id) column — but the "one engine, one type, for consistency" option isn't unreasonable, and which one wins depends on assumptions worth stating explicitly rather than picking silently.
- The case for mixing types:
SimpleAggregateFunctioncolumns read back as their plain underlying type — no-Mergeneeded, soSELECT sum(revenue), max(order_value) FROM rollup GROUP BY regionjust works, the same as querying an ordinary table. That's strictly less for every future engineer writing a query against this table to get wrong. Only the one genuinely stateful column,uniqExact, pays the-State/-Mergetax — and it has to, regardless of which option is chosen, because no simpler type covers a distinct count. - The case for uniformity (one AggregateFunction table for everything): a team that expects to add more genuinely stateful metrics to this rollup soon — a
quantileoforder_value, anavg— might reasonably prefer every column following the same-State/-Mergepattern from day one, so the query-writing convention doesn't depend on which specific function a given column happens to use. This is a real, defensible bet on future schema evolution, not a mistake — it costs a little unnecessary ceremony onsum/maxtoday in exchange for a table that won't need a type change later. - What would tip the recommendation: if this rollup is genuinely a one-off, unlikely to gain more metrics, the mixed approach's lower ongoing query-writing friction is worth more than uniformity's future-proofing, since there's no real "future" to proof against. If the team already has strong internal tooling or query templates that assume
-Mergeeverywhere, uniformity removes a special case from that tooling, which might matter more than the friction it costs on two simple columns.
The shape of the reasoning matters more than the specific pick: name which columns genuinely need serialized aggregate state and which don't, and treat "should every column follow the same pattern" as its own explicit trade-off — not an automatic default in either direction.
Case studies
A good fit: Cloudflare's HTTP analytics pipeline, revisited. The same pipeline discussed in designing-events-tables — over 100 columns, millions of requests a second — used exactly this lesson's split. Their primary aggregated tables ran on SummingMergeTree, chosen because it "allowed us to significantly reduce the number of tables required," including summing values inside Map-typed columns for dimensional data. But unique-visitor counts had no home there: their own account of the decision states plainly that "SummingMergeTree allows you to create column with such data type, it will not perform aggregation on it for records with same primary keys" for uniques, so "we had to put uniques into separate materialized view, which uses the ReplicatedAggregatingMergeTree engine and supports merge of AggregateFunction states for records with the same primary keys." That's this lesson's decision framework step 2, playing out exactly as described, at production scale: sum-shaped metrics on the simpler engine, the one metric that genuinely needed aggregate state on the more complex one — not one engine chosen for the whole pipeline. The storage math behind choosing aggregation over keeping only raw data was stark even at the time: a full year of raw request logs was estimated at "273.93 PiB," against "18.52 PiB (RF x3)" for the aggregated tables — roughly $28M a year versus $1.9M. Even so, the team's own account notes they were "still considering to store raw (non-aggregated) requests logs" alongside the aggregates — a direct, first-party illustration of decision-framework step 5: pre-aggregating doesn't have to mean discarding the ability to ask a new question of history later, if the cost of keeping both is one you're willing to pay.
A bad fit: pre-aggregating a table that never needed it. A team with a table in the low tens of millions of rows, queried a few dozen times a day by an internal reporting tool, builds a full AggregatingMergeTree pipeline — materialized view, -State columns, the works — because a blog post described it as "how ClickHouse does real analytics." The plain GROUP BY query it replaced was already returning in well under a second. What the team gained: nothing measurable a user would notice. What it cost: a second table to keep in sync, a materialized view that has to be understood by whoever debugs a discrepancy later, and a -Merge combinator every future query against that table now has to remember to include correctly. This is decision-framework step 1 skipped entirely — reaching for pre-aggregation because it's the more sophisticated-sounding tool, not because query-time aggregation was measured and found wanting.
Anti-pattern gallery
Querying an AggregateFunction column without -Merge. Unlike SummingMergeTree, where forgetting GROUP BY/sum() still returns real (if not-fully-folded) numbers, querying a raw AggregateFunction column returns its internal serialized state — not a usable number at all. This fails loudly rather than silently, but it fails on literally the first query anyone new to the table runs, which makes it a near-universal onboarding trap for a table built this way.
Assuming SummingMergeTree or AggregatingMergeTree removes the need for GROUP BY entirely. Both engines only fold rows together within a merge, and merges are eventual, exactly like replacingmergetree's deduplication. A query against a SummingMergeTree table that skips sum()/GROUP BY because "the engine already summed it" will silently undercount or overcount whenever unmerged parts happen to still exist — which, per ClickHouse's own guidance, should be assumed possible at any time, not treated as a rare edge case.
Choosing SummingMergeTree, then discovering a uniq requirement later. A table designed around SummingMergeTree for its sum-shaped metrics, then asked to also report a distinct count, can't grow that capability in place — SummingMergeTree has no partial-state mechanism to extend. The fix is a structural one (a second AggregatingMergeTree table or column, as Cloudflare's own pipeline needed), not a configuration tweak, which is exactly why decision-framework step 2 belongs early, before committing to an engine, not after.
Summary
SummingMergeTree and AggregatingMergeTree both trade query-time aggregation cost for merge-time pre-reduction — the same write-now-or-read-now trade this course has already taught twice, applied here to collapsing rows instead of deduplicating or denormalizing them. Plain MergeTree with query-time GROUP BY remains the right default until raw-data aggregation is actually measured as too slow, not merely less sophisticated-looking; building pre-aggregation machinery ahead of that point is a real, avoidable cost, illustrated by the "bad fit" case study above. SummingMergeTree handles pure sums with no insert- or query-time ceremony, but cannot express any other aggregate function — Cloudflare's own pipeline hit this limit directly and needed a second engine for unique-visitor counts. AggregatingMergeTree generalizes to any aggregate function via AggregateFunction state columns, populated with -State on write and read back with -Merge, at real complexity cost — and SimpleAggregateFunction is the practical middle ground for min/max/sum/any-shaped columns that don't need that ceremony at all. Both pre-aggregating engines inherit ReplacingMergeTree's central caveat: folding happens only during background merges, on ClickHouse's own schedule, so queries still need sum()/GROUP BY (SummingMergeTree) or the matching -Merge combinator (AggregatingMergeTree) to be correct — skipping that step doesn't just under-optimize a query, it produces wrong or meaningless results.
Quiz
1. A table's dashboard query aggregates 40 million raw rows and returns in 300ms, queried a few times an hour. A team proposes migrating it to AggregatingMergeTree for 'better performance.' What's the strongest response, applying this lesson's decision framework?
2. Why couldn't Cloudflare's pipeline use SummingMergeTree alone for both revenue-style sums and unique-visitor counts?
3. A query runs SELECT visitors FROM rollup_table (where visitors is an AggregateFunction(uniq, UInt64) column) without any -Merge combinator. What happens?
4. A table needs sum(bytes_sent) and max(response_time) as its only two rollup metrics, with no distinct counts or other stateful aggregates. What's the most proportionate engine/type choice?
5. Why does this lesson describe SummingMergeTree's 'forgot to GROUP BY' failure mode as more forgiving than AggregatingMergeTree's 'forgot -Merge' failure mode?