Building ClickHouse Materialized Views for Dashboards

A dashboard that queries raw beacons is fast on the day it is built and unusable a quarter later, because its cost grows with retention. The fix is not a bigger machine: it is storing mergeable quantile state at write time so a panel reads thousands of rows instead of billions. This guide sits under Grafana Dashboards for Web Performance and covers the rollup schema, why quantile state rather than quantile values is the whole trick, backfilling without a maintenance window, and how to keep panel latency flat.

Prerequisites

  • A ClickHouse instance with a raw events table, as set up in Setting Up a Self-Hosted RUM Pipeline with ClickHouse.
  • A settled set of cohort dimensions — route, metric, device tier, network, country. Adding one later means rebuilding.
  • Grafana or another dashboard tool pointed at the same database.

How to build it

Step 1 — Understand why storing a p75 does not work

The instinct is to compute the p75 hourly and store the number. It fails immediately: percentiles do not average. The p75 of a day is not the mean of 24 hourly p75 values, and no arithmetic on stored percentiles recovers it.

ClickHouse solves this with aggregate state: quantileTDigestState stores a compressed sketch of the distribution that can be merged. Merge 24 hourly states and you get the exact same sketch you would have built from the day’s raw rows, and quantileTDigestMerge reads a percentile out of it.

Approach Can re-aggregate to a longer window Storage per cohort-hour Accuracy
Store raw events Yes Everything Exact
Store the p75 value No 8 bytes Exact for that window only
Store quantile state Yes ~1–4 KB Approximate, ~1%

The middle row is the trap, and it is what most homegrown pipelines do first.

Step 2 — Create the rollup table

-- Hourly rollup. AggregateFunction columns hold mergeable state, not values.
CREATE TABLE rum_hourly
(
  hour          DateTime,
  metric        LowCardinality(String),
  route         LowCardinality(String),
  device_tier   LowCardinality(String),
  network       LowCardinality(String),
  country       LowCardinality(FixedString(2)),
  samples       AggregateFunction(count),
  value_state   AggregateFunction(quantilesTDigest(0.5, 0.75, 0.95, 0.99), Float32),
  good_count    AggregateFunction(sum, UInt32),
  poor_count    AggregateFunction(sum, UInt32)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour)
ORDER BY (metric, route, device_tier, network, country, hour)
TTL hour + INTERVAL 13 MONTH;

LowCardinality on every dimension is what keeps this small — those columns are dictionary-encoded, so a route repeated a million times costs almost nothing. The ordering key puts the columns dashboards filter on first, which is what makes the reads cheap.

Step 3 — Attach a materialized view so it fills itself

-- Every insert into rum_events also lands, aggregated, in rum_hourly.
CREATE MATERIALIZED VIEW rum_hourly_mv TO rum_hourly AS
SELECT
  toStartOfHour(ts)                                        AS hour,
  metric,
  route,
  device_tier,
  network,
  country,
  countState()                                             AS samples,
  quantilesTDigestState(0.5, 0.75, 0.95, 0.99)(value)      AS value_state,
  sumState(toUInt32(rating = 'good'))                      AS good_count,
  sumState(toUInt32(rating = 'poor'))                      AS poor_count
FROM rum_events
GROUP BY hour, metric, route, device_tier, network, country;

The view runs on insert, on the inserted block only. It never scans the existing table, so adding it to a live pipeline is safe — it simply starts producing rows from the next batch onward.

Step 4 — Read percentiles back out

-- A dashboard panel: p75 by route over an arbitrary window.
SELECT
  route,
  countMerge(samples)                                        AS n,
  quantilesTDigestMerge(0.5, 0.75, 0.95, 0.99)(value_state)  AS q,
  q[2]                                                       AS p75,
  sumMerge(good_count) / countMerge(samples)                 AS good_share
FROM rum_hourly
WHERE metric = 'LCP'
  AND hour >= now() - INTERVAL 28 DAY
  AND device_tier = {tier:String}
GROUP BY route
HAVING n >= 1000
ORDER BY p75 DESC;

The Merge combinators are the counterpart to the State ones. Any window, any subset of dimensions, one pass over a small table.

Panel latency as retention growsRaw-table cost grows with retention; rollup cost does not. That divergence is the entire argument for materialized views.Panel latency as retention growsSame 28-day p75 query, measured weekly0 ms2,500 ms5,000 ms7,500 ms10,000 msusable panel limitw1w4w8w12w16w20w24Against raw eventsAgainst the hourly rollup
Raw-table cost grows with retention; rollup cost does not. That divergence is the entire argument for materialized views.

Step 5 — Add a daily rollup on top

For year-long trends, merge the hourly states into daily ones. The same pattern applies, and a view can read from a view.

CREATE TABLE rum_daily AS rum_hourly
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (metric, route, device_tier, day);

CREATE MATERIALIZED VIEW rum_daily_mv TO rum_daily AS
SELECT
  toStartOfDay(hour)                     AS day,
  metric, route, device_tier, network, country,
  countMergeState(samples)               AS samples,
  quantilesTDigestMergeState(0.5, 0.75, 0.95, 0.99)(value_state) AS value_state,
  sumMergeState(good_count)              AS good_count,
  sumMergeState(poor_count)              AS poor_count
FROM rum_hourly
GROUP BY day, metric, route, device_tier, network, country;

MergeState is the combinator that merges existing state and keeps it as state — the mechanism that lets rollups stack without ever collapsing to a number.

Step 6 — Backfill without blocking ingestion

A new view only sees new inserts, so history has to be backfilled explicitly. Do it a partition at a time so a failure costs one month rather than the whole job.

-- Backfill one month, then repeat. Watch memory: a wide GROUP BY over a
-- large partition is the part that fails on a small instance.
INSERT INTO rum_hourly
SELECT
  toStartOfHour(ts) AS hour, metric, route, device_tier, network, country,
  countState(),
  quantilesTDigestState(0.5, 0.75, 0.95, 0.99)(value),
  sumState(toUInt32(rating = 'good')),
  sumState(toUInt32(rating = 'poor'))
FROM rum_events
WHERE ts >= '2026-06-01' AND ts < '2026-07-01'
GROUP BY hour, metric, route, device_tier, network, country
SETTINGS max_memory_usage = 8000000000, max_threads = 4;

Run the backfill after the view is live, and check for double-counting at the boundary: if the view was created mid-hour, that hour will contain both view-generated and backfilled rows. Deleting the boundary partition before backfilling it is the simplest fix.

Storage per month, raw against rollupsThe last row is cheapest and cannot answer a question it was not built for. The daily rollup costs seven times as much and can be re-cut by any dimension it carries.Storage per month, raw against rollups25M sessions a month, five metrics per sessionRaw events (90-day TTL)410 GBHourly rollup (13 months)18 GBDaily rollup (13 months)1.4 GBStored p75 values only0.2 GB0 GB125 GB250 GB375 GB500 GB
The last row is cheapest and cannot answer a question it was not built for. The daily rollup costs seven times as much and can be re-cut by any dimension it carries.

Choosing the grain

The single most consequential decision is which dimensions go into the rollup, because it fixes what you can ask later.

Every dimension multiplies the row count. Metric (5) × route (200) × device tier (4) × network (5) × country (40) × hours (720/month) is a theoretical 5.7 billion rows a month — except that most combinations never occur, and the real count is typically a fraction of a percent of that. Still, the sparsity is doing the work rather than the design.

Two rules keep it manageable. Drop dimensions that are never filtered on together: if nobody ever asks for country crossed with network, keep two narrower rollups instead of one wide one. And cap the cardinality of every dimension at the collector, so a client bug cannot introduce ten thousand new route values overnight — the validation layer for that is in Rate Limiting and Validating RUM Beacons.

Where a dimension is needed occasionally but not routinely, leave it out of the rollup and query the raw table for those investigations. A rollup that answers 95% of questions in 50 ms plus a raw table for the other 5% is a better system than one rollup that answers everything slowly.

Verifying it works

  1. Rollup percentiles match raw percentiles within about 1% on a sample window. A larger gap means the t-digest parameters need tightening or the backfill is incomplete.
  2. Panel latency is flat over time. Measure the same query weekly; growth means a panel is still reading raw data.
  3. Sample counts agree between rollup and raw for the same window. A mismatch points at a boundary double-count or a dropped block.
  4. The daily rollup agrees with the hourly one re-aggregated over a day. This checks the MergeState chain end to end.
  5. New dimension values appear in the rollup within an hour of first being sent.
State in, state out, value at the endThe percentile is computed once, at the end, from whatever window the reader asked for. Everything before that stays mergeable.State in, state out, value at the endWhy a rollup can be re-aggregated and a stored percentile cannotRaw eventsone row per beaconquantileStatemergeable sketchMergeStatehourly into dailyquantileMergea number, at read time
The percentile is computed once, at the end, from whatever window the reader asked for. Everything before that stays mergeable.

Edge cases and gotchas

  • Materialized views do not see historical data. Creating one is not a migration; the backfill is a separate, explicit job.
  • They also do not see ALTERs. Changing the raw table schema does not update the view’s SELECT; both have to change together.
  • State columns are engine-specific binaries. They cannot be read by anything but ClickHouse, and their format is tied to the function and its parameters.
  • AggregatingMergeTree merges in the background. Immediately after insert you may see several partial rows per key; the Merge combinator handles that correctly, a naive SELECT does not.
  • Approximate quantiles have error. t-digest is roughly 1% at the median and better in the tails. Where exactness matters, keep raw data for that window.
  • TTL deletes silently. A 13-month TTL on the rollup and 90 days on raw means year-old data cannot be recomputed if the rollup grain turns out to be wrong.

FAQ

Why not just store the p75 per hour?

Because percentiles do not average, so an hourly p75 cannot be re-aggregated into a daily or weekly one. Quantile state can be merged; a stored value cannot.

How much storage does the rollup cost?

Typically 1–4 KB per cohort-hour for a four-quantile t-digest. On a site with a few hundred live cohort combinations that is a few gigabytes a year — orders of magnitude less than the raw events.

Can Grafana query aggregate state directly?

Yes, as long as the SQL uses the Merge combinators. Grafana sees ordinary numbers; the merging happens server-side.

What if I need a dimension I did not include?

Query the raw table for that investigation, and add the dimension to a new rollup if the question recurs. Rebuilding a rollup is a backfill, which is a job rather than a crisis.