Pre-Aggregation
One dashboard, 2.4 billion rows
Take a simplified model of that team's data at Uber. Every completed trip is 1 row in trips: trip_id, rider_id, driver_id, country, city, vehicle_type, started_at, fare, distance_km. After 6 years the table holds 2.4 billion rows, about 900GB on disk. The dashboard charts trips and fare per city per day for the last 90 days, and this is the query behind it.
Compute once, read many
| city | trip_date | trip_count | fare_total |
|---|---|---|---|
| Austin | 2026-10-21 | 18,412 | 341,207.50 |
| Austin | 2026-10-22 | 19,006 | 352,118.25 |
| Berlin | 2026-10-21 | 42,880 | 619,330.00 |
| Berlin | 2026-10-22 | 41,215 | 598,004.75 |
What you give up
- ▸Filtering by driver or rider is impossible: those columns were grouped away and no query can bring them back.
- ▸Hourly trends are gone: a row holds a whole day, and a day cannot be split back into hours.
- ▸Numbers lag the raw table by up to one job cycle, so a trip at 11pm shows up tomorrow.
- ▸Someone owns the job: when it fails, the newest day goes missing and nothing raises an error.
A product manager asks why the nightly summary job cannot be dropped and the dashboard pointed straight at trips on a bigger cluster. Which answer is right?
Roll up: a summary of a summary
| city | trip_date | trip_count | fare_total | distinct_riders | avg_fare |
|---|---|---|---|---|---|
| Austin | 2026-10-20 | 10,000 | 150,000.00 | 5,200 | 15.00 |
| Austin | 2026-10-21 | 18,412 | 341,207.50 | 9,800 | 18.53 |
| Austin | 2026-10-22 | 19,006 | 352,118.25 | 10,100 | 18.53 |
3 kinds of metric
| Examples | Kind | How it rolls up |
|---|---|---|
| trip_count, fare_total, distance_km | Additive | SUM across any column: city, day, vehicle type |
| drivers_online, account_balance | Semi-additive | SUM across cities or accounts; last value or average across time |
| distinct_riders, avg_fare, p95_wait | Non-additive | Cannot be rolled up from the stored number; store its ingredients instead |
Semi-additive metrics measure a level at an instant rather than a flow over a period. drivers_online at 9am in Austin plus drivers_online at 9am in Berlin is a real number, the drivers online across both cities. drivers_online at 9am plus drivers_online at 10am counts the same drivers twice. Rolling a snapshot across time means choosing one instant (the last reading of the month) or averaging the readings, and the column name must say which, so a reader of drivers_online_month_end knows what the number is.
Store the parts, never the answer
Percentiles have the same shape: the p95 of 30 daily p95 values is not the monthly p95. Store a quantile digest, a sketch of the value distribution that merges the same way, or keep the finer rows and compute the percentile from them.
A daily summary stores conversion_rate as bookings divided by searches. A weekly dashboard averages the 7 daily rates. Why is the weekly figure wrong, and what fixes it?
Many groupings, one table
| city | vehicle_type | trip_count | fare_total | grouping_id |
|---|---|---|---|---|
| Austin | sedan | 14,200 | 263,000.00 | 0 |
| Austin | xl | 4,806 | 89,118.25 | 0 |
| Austin | NULL | 19,006 | 352,118.25 | 1 |
| NULL | sedan | 820,300 | 14,765,400.00 | 2 |
| NULL | xl | 281,100 | 5,246,400.00 | 2 |
| NULL | NULL | 1,101,400 | 20,011,800.00 | 3 |
NULL in a dimension column means 'all values', and grouping_id records which columns were collapsed: bit value 1 for vehicle_type, bit value 2 for city. The id column matters because a real city value can be missing too, and a trip with an unknown city would otherwise look like the all-cities row. A dashboard reading the cube filters on grouping_id to pick a view: grouping_id = 1 gives one total per city, grouping_id = 3 gives the single company row, and the Austin row with id 1 holds 19,006 trips, the sum of its 2 rows with id 0.
Rollups for hierarchies, grouping sets for choice
ROLLUP covers a hierarchy. ROLLUP (country, city) produces (country, city), (country) and (), and skips (city) alone, since a city belongs to one country and the (country, city) rows already hold its total. GROUPING SETS lists the exact combinations you want and nothing else. The trips table has a country column, so the 3 dimensions country, city and vehicle_type can be trimmed to 5 groupings.
| Clause over (country, city, vehicle_type) | Groupings | Rows per day with 20 countries, 300 cities, 4 types |
|---|---|---|
| CUBE | 8 | 3,105 |
| ROLLUP | 4 | 1,521 |
| GROUPING SETS above | 5 | 1,601 |
- ▸Filter on the grouping id, or on which dimension columns are NULL, before summing anything; rows with different ids overlap.
- ▸A non-additive measure is computed from raw rows or merged sketches inside each grouping, never derived from a finer grouping in the same table.
A cube over (city, vehicle_type) is stored for each day in trips_cube. An analyst wants the sedan fare total across all cities on 2026-10-22 and runs the query below. What does it return?
What one row means
The granularity of a summary is the meaning of one row: the set of columns that make a row unique and the time bucket it covers. trips_daily_city has the granularity (city, day). Every design decision about a summary is a decision about granularity, because the granularity fixes both the questions the table can answer and its size.
| Granularity | Rows per day | Rows per year | Answers | Cannot answer |
|---|---|---|---|---|
| (day) | 1 | 365 | company-wide daily trend | anything by city |
| (city, day) | 300 | 109,500 | city trends and rankings | hourly peaks, vehicle mix |
| (city, vehicle_type, hour) | 28,800 | 10.5M | peak hours per city and type | anything about one rider |
| (rider_id, day) | 1.1M | 400M | per-rider daily activity | nothing a raw query cannot do as cheaply |
Finest grain first, coarser grains from it
Each arrow between tables is a GROUP BY over the table to its left; only the first arrow reads raw trips.
Columns that belong in the granularity
You own `trips_daily_city`, the (city, day) summary behind the operations dashboard. Over 3 quarters, new teams arrive with requests. Each choice changes the table's granularity, its size, and what it can answer.
| city | trip_date | trip_count | fare_total | riders_sketch |
|---|
The marketplace team wants the share of trips by vehicle_type per city per day. How do you change the summary?
3 ways to keep a summary current
| Strategy | Raw rows read per run | Cost over the years | Rows that arrive late |
|---|---|---|---|
| Full rebuild | all 2.4B | grows with the raw table | always included |
| Incremental append | 1 day, about 1.1M | flat | missed once their day is written |
| Trailing window rebuild | last 3 days, about 3.3M | flat | included if under 3 days late |
Full rebuild
Incremental append
Incremental append breaks on 2 kinds of row. A trip that started at 23:58 on the 22nd but reached trips at 02:10 on the 23rd, after the job ran, belongs to a day that is already summarized, and tomorrow's run filters it out by date, so it is never counted. A job rerun by hand after a failure inserts the 22nd a second time, and the dashboard shows Austin at 38,012 trips instead of 19,006.
Trailing window rebuild
A rerun deletes and rewrites the same 3 days, so it leaves the rows a single run would.
Trust the numbers after the job
The trips_daily_city job appends one day per run, with the date as a parameter. Tuesday's 02:00 run inserted Monday's rows, then reported a failure in a later step. On Wednesday morning, after the normal run had appended Tuesday, an engineer reran the job for Monday's date. How do Monday and Tuesday look in the summary?
> A food delivery company serves 2,000 restaurants across 50 cities, and its restaurant partners open a performance dashboard every morning: orders, revenue, distinct customers and median delivery time per restaurant per day for the last 30 days, with a city-level view for the account managers.
Compute the GROUP BY once, store the result, and let every dashboard read 27,000 rows instead of 2 billion.
- Category
- Data Modeling
- Difficulty
- intermediate
- Duration
- 23 minutes
- Challenges
- 12 hands-on challenges
Topics covered: Why Pre-Aggregate?, Metric Types and Additivity, OLAP Cubes & Rollups, Granularity Design, Refresh Strategies for Aggregates
Lesson Sections
- Why Pre-Aggregate? (concepts: Pre-Aggregation)
One dashboard, 2.4 billion rows The engine reads every row from the last 90 days, about 100 million of them, groups them into 300 cities by 90 days, and returns 27,000 rows. On a shared cluster that takes 40 seconds. Each morning 40 analysts open the dashboard, each tab re-runs the query, and every filter click (one city, one week) re-runs it again with a narrower WHERE clause. The same 100 million rows are read hundreds of times a day to produce the same 27,000 numbers. Compute once, read many
- Metric Types and Additivity (concepts: Metric Additivity)
Roll up: a summary of a summary The nightly table holds one row per city per day. A monthly report wants one row per city per month, and the cheapest way to produce it reads the daily table rather than the raw trips. Combining finer rows into coarser rows is a roll up, and whether the result is right depends on the metric. Suppose the nightly job also stored distinct_riders and avg_fare for each day. Summing trip_count across the 3 days gives 47,418, and summing fare_total gives 843,325.75. Both
- OLAP Cubes & Rollups (concepts: OLAP Cubes)
Many groupings, one table OLAP, online analytical processing, is the name for queries that slice a measure by one grouping column after another: trips by city, then by vehicle type, then by city and vehicle type together, then the company total. Each slice is a GROUP BY with a different column list, and a summary that holds only (city, day) rows cannot serve the vehicle type view. A column used for grouping like this is called a dimension, and a cube is a summary that holds every combination of
- Granularity Design (concepts: Grain Definition)
What one row means The last row fails. A high-cardinality column in the granularity gives the summary about as many rows as the raw table, since most riders take 1 trip a day, and a summary the size of its source saves nothing. For 300 cities the third row fits best: it answers every hourly peak question and still sits about 38 times below the raw table, 28,800 rows a day against 1.1 million trips. Finest grain first, coarser grains from it Pick the finest granularity any current consumer needs,
- Refresh Strategies for Aggregates
3 ways to keep a summary current A summary is a cache of a GROUP BY, and like any cache it goes stale the moment new rows land in the raw table. The refresh strategy decides how much work each update costs and how far behind the raw table the summary may fall. Full rebuild Drop the summary and run the CREATE TABLE AS from the first section again. The result is correct by construction, since every raw row is read every time, and the job is 1 statement. Cost is the problem: the run reads 2.4 billi