Skip to main content

Command Palette

Search for a command to run...

Pre-aggregation gets you 100× — until someone applies a filter

A cube makes some questions instant and a larger set unanswerable. Design for the boundary.

Updated
6 min readView as Markdown
A
Full-stack engineer passionate about backend architecture

You have a dashboard over a large fact table. Tens of millions of new rows a month, thirty tiles on the page, and every tile is an aggregate: orders by channel, revenue by region, units by device. Each one scans a lot of rows to produce a handful of numbers.

The fix is obvious and correct: stop computing the same answers over and over. Precompute them. Store the answers instead of the rows.

Done well this is one of the largest wins available in analytics — two orders of magnitude, routinely. It also has a limit that is easy to miss at design time and impossible to miss once a user clicks a filter. The limit is the interesting part, because it determines the architecture of everything downstream.

The generic cube

The naive version of pre-aggregation is one materialized view per tile. It works and it doesn't scale organizationally: every new tile is a schema change, a migration, and a backfill.

So you generalise. Instead of a table per question, store aggregates in one long-format table where the dimension itself is data:

-- synthetic pre-aggregation cube
day          Date          -- bucket
dim_name     LowCardinality(String)   -- 'channel' | 'region' | 'device'
dim_value    LowCardinality(String)   -- 'web' | 'north' | 'mobile'
orders       UInt64
revenue      Decimal(18, 2)
units        UInt64
-- engine: summing / aggregating, keyed on (day, dim_name, dim_value)

The ETL writes one row per day per dimension per value. A summing engine collapses rows sharing that key during merges, so ingest can be dumb and append-only.

Now any single-dimension tile is the same trivial query:

SELECT dim_value, sum(orders) AS orders
FROM cube
WHERE day BETWEEN ? AND ?
  AND dim_name = 'channel'
GROUP BY dim_value;

Row counts tell you why it's fast. The fact table grows with events. The cube grows with days × dimensions × distinct values — thousands of rows where the facts have hundreds of millions. Nothing is scanned that isn't an answer.

The limit: one slice per row

Read a cube row as a sentence: on this day, for channel = web, here are the metrics.

Notice what it does not say. It says nothing about region. Nothing about device. Those dimensions weren't compressed or deferred — they were summed away. That row is a total across every region and every device.

So:

  • "orders by channel" — the cube answers it instantly.

  • "orders by channel, where region = north" — the cube cannot answer it at all. Not slowly. Not approximately. The channel rows have no idea which regions contributed.

This is the property to internalise: a long-format cube supports one-dimensional slicing only. The speed comes precisely from having thrown away the cross-dimensional detail. You can't get it back from the cube because it isn't in there.

And the failure is asymmetric in a nasty way. Unfiltered, every tile is instant. Apply one filter and every tile that can't express it has to go somewhere else — so the page doesn't degrade gracefully, it falls off a cliff at the exact moment the user starts exploring.

Three responses

Option What it buys What it costs
Composite dimensions Store `channel regionas its owndim_name`. The filtered tile stays fast.
Raw fallback Always correct, works for any filter. You lose the 100× exactly when the user is most engaged. Applied page-wide, one filter makes thirty tiles slow.
Capability-aware routing Per-tile decision: cube when it can answer, raw when it can't. You now own an explicit model of what the cube supports, and it must not drift from what the ETL writes.

Composite dimensions are worth it for two or three known-hot pairs and worth nothing as a general strategy — the moment you're enumerating combinations, the cube has become a worse version of the fact table.

Raw fallback is the honest baseline, and its real flaw is granularity: it's usually implemented as a page-level decision when it should be a tile-level one. A filter on region doesn't stop the region tile from being expressible.

Teach the planner what the cube can honour

The version that holds up: keep an explicit registry of which dimensions exist in the cube, and route each tile independently.

# dimensions the cube physically materialises
CUBE_DIMENSIONS = {"channel", "region", "device"}

def plan_tile(group_by: str, filters: dict) -> str:
    if group_by not in CUBE_DIMENSIONS:
        return "raw"                      # can't even group on it

    unsupported = set(filters) - CUBE_DIMENSIONS
    if unsupported:
        return "raw"                      # filter not expressible in the cube

    if set(filters) - {group_by}:
        return "raw"                      # cross-dimensional: cube summed it away
    return "cube"

Three checks, and the third is the one people forget: a filter on a dimension the cube has still forces raw data if it isn't the dimension being grouped by. group_by=channel, filters={region} is a cross-dimensional question, and no amount of the cube containing a region dimension makes it answerable.

Two properties matter more than the code:

Allowlist, not heuristic. CUBE_DIMENSIONS enumerates what the ETL actually writes. Don't infer capability from the filter's shape or from whether a lookup returns rows — an empty cube result is indistinguishable from a genuine zero.

Fail safe toward slow. An unknown dimension routes to raw data. The failure mode of guessing wrong in that direction is a slow tile. The failure mode of guessing wrong in the other direction is a confidently wrong number, which in analytics is a broken product rather than a performance issue.

When not to build a cube

Pre-aggregation is a bet that your read patterns are predictable. It pays enormously when users mostly slice one dimension at a time over closed date ranges — which describes most dashboards most of the time.

It pays badly when users compose arbitrary filters, because then you're routing to raw data on most requests and maintaining a cube for the minority case. If that's your traffic, the honest move is to skip the cube and spend the effort on the raw path instead: sort key choice, projections, skip indexes. Measure the shape of real queries before committing, because the cube's value is entirely a function of that shape.

The principle

Pre-aggregation doesn't compress your data. It discards the dimensions you didn't group by, and the speed is the discarding. Every cube is a set of questions made instant and a larger set made unanswerable.

Which means the design question isn't how do I make the cube faster. It's which questions am I willing to make unanswerable — and then building a system that knows the difference, instead of one that finds out at query time.