From 03604d069359542f375786401515098c2e2760fc Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 18:46:30 +0000 Subject: [PATCH 1/5] docs: add recipe for matching multi-stage measures in pre-aggregations --- docs-mintlify/docs.json | 1 + docs-mintlify/recipes/index.mdx | 3 + .../matching-multi-stage-measures.mdx | 463 ++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 724945f33bb93..e1fd323907ece 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -631,6 +631,7 @@ "group": "Pre-aggregations", "pages": [ "recipes/pre-aggregations/non-additivity", + "recipes/pre-aggregations/matching-multi-stage-measures", "recipes/pre-aggregations/incrementally-building-pre-aggregations-for-a-date-range", "recipes/pre-aggregations/disabling-pre-aggregations", "recipes/pre-aggregations/using-originalsql-and-rollups-effectively", diff --git a/docs-mintlify/recipes/index.mdx b/docs-mintlify/recipes/index.mdx index 4b9406ea8797b..0264b57d927f7 100644 --- a/docs-mintlify/recipes/index.mdx +++ b/docs-mintlify/recipes/index.mdx @@ -93,6 +93,9 @@ pre-aggregations, configuration, APIs, and AI. Accelerate averages, distinct counts, and similar non-additive measures with pre-aggregations. + + Match time shifts, period-to-date, percent of total, ranks, and nested aggregates with rollups. + Rebuild only the time-bounded partitions you need instead of refreshing entire rollups. diff --git a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx new file mode 100644 index 0000000000000..bf6005158f77a --- /dev/null +++ b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx @@ -0,0 +1,463 @@ +--- +title: Matching multi-stage measures in pre-aggregations +description: How to design rollup pre-aggregations so multi-stage measures (time shifts, period-to-date, percent of total, ranks, and nested aggregates) are matched and accelerated. +--- + +## Use case + +We use [multi-stage measures][ref-multi-stage] — measures that aggregate over +already-aggregated data — for things like year-over-year comparisons, +period-to-date totals, percent of total, ranks, and nested aggregates. We want +those queries to be accelerated by +[pre-aggregations][ref-pre-aggs] instead of falling back to the +upstream data source. + +Multi-stage measures don't slot into rollups the same way as plain additive +measures: the values themselves cannot be re-aggregated from a rollup. Cube +matches them by including the **underlying base measures** in the rollup and +recomputing the multi-stage CTEs on top of the pre-aggregation at query time. + +This recipe shows the rules for building a rollup that matches multi-stage +measures, plus patterns for the most common cases. + + + +Multi-stage measures are powered by Tesseract, the [next-generation data +modeling engine][link-tesseract]. Tesseract is currently in preview. Set +[`CUBEJS_TESSERACT_SQL_PLANNER`][ref-tesseract-env] to `true` to enable it. + + + +## How matching works + +When a query references a multi-stage measure, Cube looks for a rollup that +satisfies **three** conditions at once: + +1. **Base measures are included.** Every measure referenced inside the + multi-stage `sql` expression (directly or transitively) must be a member of + the rollup. The multi-stage measure itself is **not** included in + `measures` — only its building blocks. +2. **All driving dimensions are included.** Anything the multi-stage parameter + partitions by must be present in the rollup's `dimensions`: + - For [`group_by`][ref-group-by] / [`add_group_by`][ref-add-group-by]: + every listed dimension. + - For [`reduce_by`][ref-reduce-by]: every listed dimension, plus the + dimensions the query groups by. + - For [`time_shift`][ref-time-shift]: the shifted + [`time_dimension`][ref-time-dim] and a `granularity` at least as fine + as the shift interval and the query's time granularity. +3. **The base measures stay additive.** The rollup can only be used if the + inner aggregation can be recomputed from the rollup rows. Use + [additive types][ref-additive] (`count`, `sum`, `min`, `max`, + `count_distinct_approx`) for the base. Avoid `avg` and `count_distinct` as + bases — decompose them into `sum` + `count` and `count_distinct_approx` + respectively, the same way as for + [non-additive measures][ref-non-additivity]. + +If any of these are missing, the rollup is skipped for the multi-stage query +and Cube either picks another pre-aggregation or falls back to the data source. + +## Pattern 1: time shift (year-over-year) + +A [time-shift][ref-time-shift] measure compares the current period against a +shifted one: + + + +```yaml title="YAML" +cubes: + - name: orders + sql_table: orders + + measures: + - name: revenue + sql: amount + type: sum + + - name: revenue_prior_year + multi_stage: true + sql: "{revenue}" + type: number + time_shift: + - time_dimension: created_at + interval: 1 year + type: prior + + dimensions: + - name: status + sql: status + type: string + + - name: created_at + sql: created_at + type: time + + pre_aggregations: + - name: revenue_by_status + # Only the *base* measure goes in. + measures: + - revenue + dimensions: + - status + # Shift dimension must be the time_dimension of the rollup, + # and its granularity must be at least as fine as the shift interval + # and the query's requested granularity. + time_dimension: created_at + granularity: day + partition_granularity: month + # Build range must cover BOTH the queried range AND the shifted range. + # For a `1 year prior` shift, extend the start of the build range. + build_range_start: + sql: "SELECT NOW() - INTERVAL '3 years'" + build_range_end: + sql: "SELECT NOW()" +``` + +```javascript title="JavaScript" +cube(`orders`, { + sql_table: `orders`, + + measures: { + revenue: { sql: `amount`, type: `sum` }, + + revenue_prior_year: { + multi_stage: true, + sql: `${revenue}`, + type: `number`, + time_shift: [ + { time_dimension: `created_at`, interval: `1 year`, type: `prior` }, + ], + }, + }, + + dimensions: { + status: { sql: `status`, type: `string` }, + created_at: { sql: `created_at`, type: `time` }, + }, + + pre_aggregations: { + revenue_by_status: { + measures: [revenue], + dimensions: [status], + time_dimension: created_at, + granularity: `day`, + partition_granularity: `month`, + build_range_start: { sql: `SELECT NOW() - INTERVAL '3 years'` }, + build_range_end: { sql: `SELECT NOW()` }, + }, + }, +}); +``` + + + +A query like: + +```json +{ + "measures": ["orders.revenue", "orders.revenue_prior_year"], + "dimensions": ["orders.status"], + "timeDimensions": [ + { "dimension": "orders.created_at", "granularity": "month", + "dateRange": ["2024-01-01", "2024-12-31"] } + ] +} +``` + +matches `revenue_by_status` because: + +- the base `revenue` is in the rollup, +- `status` is in the rollup, +- the rollup's `created_at` granularity (`day`) is finer than the requested + `month`, and +- the build range covers `2023-01-01..2024-12-31` (the queried year plus the + one-year shift). + + + +A common pitfall is a build range that covers the queried range but not the +shift. The shifted side of the time-shift CTE silently returns no rows and the +comparison comes back as `NULL`. Extend `build_range_start` by the largest +shift interval used by any multi-stage measure on the rollup. + + + +## Pattern 2: period-to-date + +Period-to-date measures (`rolling_window: { type: to_date, granularity: ... }`) +don't need `multi_stage: true`, but they share the same matching rules: the +rollup must include the base measure, all queried dimensions, and a +`time_dimension` whose granularity divides the period-to-date granularity. + + + +```yaml title="YAML" +measures: + - name: revenue_ytd + sql: amount + type: sum + rolling_window: + type: to_date + granularity: year + +pre_aggregations: + - name: revenue_ytd_by_status + measures: + - revenue_ytd + dimensions: + - status + time_dimension: created_at + # Use the finest granularity the rolling window needs to land on. + granularity: day + partition_granularity: month +``` + +```javascript title="JavaScript" +measures: { + revenue_ytd: { + sql: `amount`, + type: `sum`, + rolling_window: { type: `to_date`, granularity: `year` }, + }, +}, + +pre_aggregations: { + revenue_ytd_by_status: { + measures: [revenue_ytd], + dimensions: [status], + time_dimension: created_at, + granularity: `day`, + partition_granularity: `month`, + }, +}, +``` + + + +For `to_date` windows, the rollup `granularity` must be **finer than or equal +to** the `to_date` `granularity` — otherwise Cube cannot truncate the +pre-aggregated rows to the boundary of the period. + +## Pattern 3: percent of total with `group_by` + +Percent-of-total uses [`group_by`][ref-group-by] to fix the inner aggregation +to specific dimensions: + + + +```yaml title="YAML" +measures: + - name: revenue + sql: amount + type: sum + + - name: country_revenue + multi_stage: true + sql: "{revenue}" + type: sum + group_by: + - country + + - name: country_revenue_percentage + multi_stage: true + sql: "{revenue} / NULLIF({country_revenue}, 0)" + type: number + +pre_aggregations: + - name: revenue_by_country_and_product: + measures: + - revenue + # Must include every dimension referenced in any group_by used by the + # query's multi-stage measures, PLUS every dimension the query groups by. + dimensions: + - country + - product +``` + +```javascript title="JavaScript" +measures: { + revenue: { sql: `amount`, type: `sum` }, + + country_revenue: { + multi_stage: true, + sql: `${revenue}`, + type: `sum`, + group_by: [country], + }, + + country_revenue_percentage: { + multi_stage: true, + sql: `${revenue} / NULLIF(${country_revenue}, 0)`, + type: `number`, + }, +}, + +pre_aggregations: { + revenue_by_country_and_product: { + measures: [revenue], + dimensions: [country, product], + }, +}, +``` + + + +The rollup carries `revenue` grouped by `country` **and** `product`. Cube +re-aggregates it to `country` for the inner CTE, then divides at the outer +stage to produce the percentage. If the rollup omitted `country`, the inner +`group_by: [country]` could not be computed and the match would fail. + +## Pattern 4: nested aggregates with `add_group_by` + +`add_group_by` computes an aggregate of an aggregate (e.g., the average of +per-customer averages). The rollup must include the `add_group_by` dimension: + + + +```yaml title="YAML" +measures: + - name: order_total + sql: amount + type: sum + + - name: avg_customer_total + multi_stage: true + sql: "{order_total}" + type: avg + add_group_by: + - customer_id + +pre_aggregations: + - name: orders_by_customer + measures: + - order_total + dimensions: + - customer_id + - region +``` + +```javascript title="JavaScript" +measures: { + order_total: { sql: `amount`, type: `sum` }, + + avg_customer_total: { + multi_stage: true, + sql: `${order_total}`, + type: `avg`, + add_group_by: [customer_id], + }, +}, + +pre_aggregations: { + orders_by_customer: { + measures: [order_total], + dimensions: [customer_id, region], + }, +}, +``` + + + +A query grouping by `region` and selecting `avg_customer_total` matches: +Cube computes `SUM(amount)` per `(region, customer_id)` from the rollup, then +averages over `customer_id` per `region` in the outer CTE. + +## Pattern 5: ranking with `reduce_by` + +[`reduce_by`][ref-reduce-by] ranks rows within a group. The rollup must +include both the rank's `reduce_by` dimension and the query's grouping +dimensions: + + + +```yaml title="YAML" +measures: + - name: revenue + sql: amount + type: sum + + - name: product_rank_in_country + multi_stage: true + type: rank + order_by: + - sql: "{revenue}" + dir: desc + reduce_by: + - product + +pre_aggregations: + - name: revenue_by_country_and_product + measures: + - revenue + dimensions: + - country + - product +``` + +```javascript title="JavaScript" +measures: { + revenue: { sql: `amount`, type: `sum` }, + + product_rank_in_country: { + multi_stage: true, + type: `rank`, + order_by: [{ sql: `${revenue}`, dir: `desc` }], + reduce_by: [product], + }, +}, + +pre_aggregations: { + revenue_by_country_and_product: { + measures: [revenue], + dimensions: [country, product], + }, +}, +``` + + + +Both `product` (from `reduce_by`) and `country` (from the query's `GROUP BY`) +must be in the rollup so Cube can rank `product` within each `country`. + +## Common pitfalls + +- **Don't list the multi-stage measure in `measures`.** Only its base measures + belong in the rollup. Multi-stage measures are computed on top of the + rollup, not stored in it. +- **Use additive bases.** `avg` and `count_distinct` make the rollup + non-additive and disqualify it from multi-stage matching. Replace them with + `sum` + `count` (and compute the average at query time) and with + `count_distinct_approx`. See the + [non-additive measures recipe][ref-non-additivity] for the full pattern. +- **Match granularity end-to-end.** For `time_shift` and `rolling_window`, + the rollup's `granularity` must be at least as fine as both the query's + granularity and the shift / window granularity. +- **Cover the shifted range.** Extend `build_range_start` backward (or + `build_range_end` forward) by the largest `time_shift` interval used. The + shifted CTE reads from the same rollup as the current period. +- **Include every `group_by` / `add_group_by` / `reduce_by` dimension** in + the rollup, in addition to the dimensions the query itself groups by. + +## See also + +- [Multi-stage measures][ref-multi-stage] — concept and parameters +- [Matching queries with pre-aggregations][ref-matching] — general matching rules +- [Accelerating non-additive measures][ref-non-additivity] — how to keep base + measures additive +- [Calculating share of total][ref-share-of-total] — an end-to-end + multi-stage example + + +[ref-multi-stage]: /docs/data-modeling/measures#multi-stage-measures +[ref-pre-aggs]: /docs/pre-aggregations +[ref-matching]: /docs/pre-aggregations/matching-pre-aggregations +[ref-non-additivity]: /recipes/pre-aggregations/non-additivity +[ref-share-of-total]: /recipes/data-modeling/share-of-total +[ref-additive]: /docs/pre-aggregations/getting-started-pre-aggregations#ensuring-pre-aggregations-are-targeted-by-queries-additivity +[ref-time-shift]: /reference/data-modeling/measures#time_shift +[ref-time-dim]: /reference/data-modeling/pre-aggregations#time_dimension +[ref-group-by]: /reference/data-modeling/measures#group_by +[ref-add-group-by]: /reference/data-modeling/measures#add_group_by +[ref-reduce-by]: /reference/data-modeling/measures#reduce_by +[ref-tesseract-env]: /reference/configuration/environment-variables#cubejs_tesseract_sql_planner +[link-tesseract]: https://cube.dev/blog/introducing-tesseract-the-data-modeling-engine-behind-cubes-universal-semantic-layer From 02a23770412669b12e35d1f0c92985816c6117b0 Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 19:05:42 +0000 Subject: [PATCH 2/5] docs: remove unverified build_range pitfall from multi-stage matching recipe --- .../matching-multi-stage-measures.mdx | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx index bf6005158f77a..b20c128fac48a 100644 --- a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx +++ b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx @@ -105,12 +105,6 @@ cubes: time_dimension: created_at granularity: day partition_granularity: month - # Build range must cover BOTH the queried range AND the shifted range. - # For a `1 year prior` shift, extend the start of the build range. - build_range_start: - sql: "SELECT NOW() - INTERVAL '3 years'" - build_range_end: - sql: "SELECT NOW()" ``` ```javascript title="JavaScript" @@ -142,8 +136,6 @@ cube(`orders`, { time_dimension: created_at, granularity: `day`, partition_granularity: `month`, - build_range_start: { sql: `SELECT NOW() - INTERVAL '3 years'` }, - build_range_end: { sql: `SELECT NOW()` }, }, }, }); @@ -167,20 +159,9 @@ A query like: matches `revenue_by_status` because: - the base `revenue` is in the rollup, -- `status` is in the rollup, +- `status` is in the rollup, and - the rollup's `created_at` granularity (`day`) is finer than the requested - `month`, and -- the build range covers `2023-01-01..2024-12-31` (the queried year plus the - one-year shift). - - - -A common pitfall is a build range that covers the queried range but not the -shift. The shifted side of the time-shift CTE silently returns no rows and the -comparison comes back as `NULL`. Extend `build_range_start` by the largest -shift interval used by any multi-stage measure on the rollup. - - + `month`. ## Pattern 2: period-to-date @@ -432,9 +413,6 @@ must be in the rollup so Cube can rank `product` within each `country`. - **Match granularity end-to-end.** For `time_shift` and `rolling_window`, the rollup's `granularity` must be at least as fine as both the query's granularity and the shift / window granularity. -- **Cover the shifted range.** Extend `build_range_start` backward (or - `build_range_end` forward) by the largest `time_shift` interval used. The - shifted CTE reads from the same rollup as the current period. - **Include every `group_by` / `add_group_by` / `reduce_by` dimension** in the rollup, in addition to the dimensions the query itself groups by. From 64ebb70e78617f70ab420bc3576de6891867bd70 Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 19:08:53 +0000 Subject: [PATCH 3/5] docs: add build_range_start/end to all rollups in multi-stage recipe --- .../matching-multi-stage-measures.mdx | 78 +++++++++++++++++-- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx index b20c128fac48a..b9e83a38fa788 100644 --- a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx +++ b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx @@ -105,6 +105,10 @@ cubes: time_dimension: created_at granularity: day partition_granularity: month + build_range_start: + sql: "SELECT date_trunc('year', CURRENT_DATE - INTERVAL '2 year')" + build_range_end: + sql: "SELECT CURRENT_DATE" ``` ```javascript title="JavaScript" @@ -136,6 +140,10 @@ cube(`orders`, { time_dimension: created_at, granularity: `day`, partition_granularity: `month`, + build_range_start: { + sql: `SELECT date_trunc('year', CURRENT_DATE - INTERVAL '2 year')`, + }, + build_range_end: { sql: `SELECT CURRENT_DATE` }, }, }, }); @@ -191,6 +199,10 @@ pre_aggregations: # Use the finest granularity the rolling window needs to land on. granularity: day partition_granularity: month + build_range_start: + sql: "SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')" + build_range_end: + sql: "SELECT CURRENT_DATE" ``` ```javascript title="JavaScript" @@ -209,6 +221,10 @@ pre_aggregations: { time_dimension: created_at, granularity: `day`, partition_granularity: `month`, + build_range_start: { + sql: `SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')`, + }, + build_range_end: { sql: `SELECT CURRENT_DATE` }, }, }, ``` @@ -245,14 +261,21 @@ measures: type: number pre_aggregations: - - name: revenue_by_country_and_product: - measures: - - revenue - # Must include every dimension referenced in any group_by used by the - # query's multi-stage measures, PLUS every dimension the query groups by. - dimensions: - - country - - product + - name: revenue_by_country_and_product + measures: + - revenue + # Must include every dimension referenced in any group_by used by the + # query's multi-stage measures, PLUS every dimension the query groups by. + dimensions: + - country + - product + time_dimension: created_at + granularity: day + partition_granularity: month + build_range_start: + sql: "SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')" + build_range_end: + sql: "SELECT CURRENT_DATE" ``` ```javascript title="JavaScript" @@ -277,6 +300,13 @@ pre_aggregations: { revenue_by_country_and_product: { measures: [revenue], dimensions: [country, product], + time_dimension: created_at, + granularity: `day`, + partition_granularity: `month`, + build_range_start: { + sql: `SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')`, + }, + build_range_end: { sql: `SELECT CURRENT_DATE` }, }, }, ``` @@ -315,6 +345,13 @@ pre_aggregations: dimensions: - customer_id - region + time_dimension: created_at + granularity: day + partition_granularity: month + build_range_start: + sql: "SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')" + build_range_end: + sql: "SELECT CURRENT_DATE" ``` ```javascript title="JavaScript" @@ -333,6 +370,13 @@ pre_aggregations: { orders_by_customer: { measures: [order_total], dimensions: [customer_id, region], + time_dimension: created_at, + granularity: `day`, + partition_granularity: `month`, + build_range_start: { + sql: `SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')`, + }, + build_range_end: { sql: `SELECT CURRENT_DATE` }, }, }, ``` @@ -373,6 +417,13 @@ pre_aggregations: dimensions: - country - product + time_dimension: created_at + granularity: day + partition_granularity: month + build_range_start: + sql: "SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')" + build_range_end: + sql: "SELECT CURRENT_DATE" ``` ```javascript title="JavaScript" @@ -391,6 +442,13 @@ pre_aggregations: { revenue_by_country_and_product: { measures: [revenue], dimensions: [country, product], + time_dimension: created_at, + granularity: `day`, + partition_granularity: `month`, + build_range_start: { + sql: `SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')`, + }, + build_range_end: { sql: `SELECT CURRENT_DATE` }, }, }, ``` @@ -415,6 +473,10 @@ must be in the rollup so Cube can rank `product` within each `country`. granularity and the shift / window granularity. - **Include every `group_by` / `add_group_by` / `reduce_by` dimension** in the rollup, in addition to the dimensions the query itself groups by. +- **Always define `build_range_start` and `build_range_end`** on partitioned + rollups. Every example in this recipe sets them — without an explicit + build range, Cube has no bound for partitioning and will fall back to a + single, unpartitioned table. ## See also From 50b824cd4f2da474e9641a227dceec04438a61dd Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 19:16:20 +0000 Subject: [PATCH 4/5] docs: remove period-to-date pattern from multi-stage matching recipe --- docs-mintlify/recipes/index.mdx | 2 +- .../matching-multi-stage-measures.mdx | 81 +++---------------- 2 files changed, 10 insertions(+), 73 deletions(-) diff --git a/docs-mintlify/recipes/index.mdx b/docs-mintlify/recipes/index.mdx index 0264b57d927f7..d697f2d2242aa 100644 --- a/docs-mintlify/recipes/index.mdx +++ b/docs-mintlify/recipes/index.mdx @@ -94,7 +94,7 @@ pre-aggregations, configuration, APIs, and AI. Accelerate averages, distinct counts, and similar non-additive measures with pre-aggregations. - Match time shifts, period-to-date, percent of total, ranks, and nested aggregates with rollups. + Match time shifts, percent of total, ranks, and nested aggregates with rollups. Rebuild only the time-bounded partitions you need instead of refreshing entire rollups. diff --git a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx index b9e83a38fa788..039c15147c7ce 100644 --- a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx +++ b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx @@ -1,13 +1,14 @@ --- title: Matching multi-stage measures in pre-aggregations -description: How to design rollup pre-aggregations so multi-stage measures (time shifts, period-to-date, percent of total, ranks, and nested aggregates) are matched and accelerated. +description: How to design rollup pre-aggregations so multi-stage measures (time shifts, percent of total, ranks, and nested aggregates) are matched and accelerated. --- ## Use case We use [multi-stage measures][ref-multi-stage] — measures that aggregate over already-aggregated data — for things like year-over-year comparisons, -period-to-date totals, percent of total, ranks, and nested aggregates. We want +period-over-period comparisons, percent of total, ranks, and nested +aggregates. We want those queries to be accelerated by [pre-aggregations][ref-pre-aggs] instead of falling back to the upstream data source. @@ -171,71 +172,7 @@ matches `revenue_by_status` because: - the rollup's `created_at` granularity (`day`) is finer than the requested `month`. -## Pattern 2: period-to-date - -Period-to-date measures (`rolling_window: { type: to_date, granularity: ... }`) -don't need `multi_stage: true`, but they share the same matching rules: the -rollup must include the base measure, all queried dimensions, and a -`time_dimension` whose granularity divides the period-to-date granularity. - - - -```yaml title="YAML" -measures: - - name: revenue_ytd - sql: amount - type: sum - rolling_window: - type: to_date - granularity: year - -pre_aggregations: - - name: revenue_ytd_by_status - measures: - - revenue_ytd - dimensions: - - status - time_dimension: created_at - # Use the finest granularity the rolling window needs to land on. - granularity: day - partition_granularity: month - build_range_start: - sql: "SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')" - build_range_end: - sql: "SELECT CURRENT_DATE" -``` - -```javascript title="JavaScript" -measures: { - revenue_ytd: { - sql: `amount`, - type: `sum`, - rolling_window: { type: `to_date`, granularity: `year` }, - }, -}, - -pre_aggregations: { - revenue_ytd_by_status: { - measures: [revenue_ytd], - dimensions: [status], - time_dimension: created_at, - granularity: `day`, - partition_granularity: `month`, - build_range_start: { - sql: `SELECT date_trunc('year', CURRENT_DATE - INTERVAL '1 year')`, - }, - build_range_end: { sql: `SELECT CURRENT_DATE` }, - }, -}, -``` - - - -For `to_date` windows, the rollup `granularity` must be **finer than or equal -to** the `to_date` `granularity` — otherwise Cube cannot truncate the -pre-aggregated rows to the boundary of the period. - -## Pattern 3: percent of total with `group_by` +## Pattern 2: percent of total with `group_by` Percent-of-total uses [`group_by`][ref-group-by] to fix the inner aggregation to specific dimensions: @@ -318,7 +255,7 @@ re-aggregates it to `country` for the inner CTE, then divides at the outer stage to produce the percentage. If the rollup omitted `country`, the inner `group_by: [country]` could not be computed and the match would fail. -## Pattern 4: nested aggregates with `add_group_by` +## Pattern 3: nested aggregates with `add_group_by` `add_group_by` computes an aggregate of an aggregate (e.g., the average of per-customer averages). The rollup must include the `add_group_by` dimension: @@ -387,7 +324,7 @@ A query grouping by `region` and selecting `avg_customer_total` matches: Cube computes `SUM(amount)` per `(region, customer_id)` from the rollup, then averages over `customer_id` per `region` in the outer CTE. -## Pattern 5: ranking with `reduce_by` +## Pattern 4: ranking with `reduce_by` [`reduce_by`][ref-reduce-by] ranks rows within a group. The rollup must include both the rank's `reduce_by` dimension and the query's grouping @@ -468,9 +405,9 @@ must be in the rollup so Cube can rank `product` within each `country`. `sum` + `count` (and compute the average at query time) and with `count_distinct_approx`. See the [non-additive measures recipe][ref-non-additivity] for the full pattern. -- **Match granularity end-to-end.** For `time_shift` and `rolling_window`, - the rollup's `granularity` must be at least as fine as both the query's - granularity and the shift / window granularity. +- **Match granularity end-to-end.** For `time_shift`, the rollup's + `granularity` must be at least as fine as both the query's granularity + and the shift interval. - **Include every `group_by` / `add_group_by` / `reduce_by` dimension** in the rollup, in addition to the dimensions the query itself groups by. - **Always define `build_range_start` and `build_range_end`** on partitioned From b04d9125835aca0b813d279c5f7044c54f3cf53f Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 19:19:16 +0000 Subject: [PATCH 5/5] docs: remove incorrect build_range pitfall from multi-stage recipe --- .../pre-aggregations/matching-multi-stage-measures.mdx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx index 039c15147c7ce..ca45d6a6e2413 100644 --- a/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx +++ b/docs-mintlify/recipes/pre-aggregations/matching-multi-stage-measures.mdx @@ -410,10 +410,6 @@ must be in the rollup so Cube can rank `product` within each `country`. and the shift interval. - **Include every `group_by` / `add_group_by` / `reduce_by` dimension** in the rollup, in addition to the dimensions the query itself groups by. -- **Always define `build_range_start` and `build_range_end`** on partitioned - rollups. Every example in this recipe sets them — without an explicit - build range, Cube has no bound for partitioning and will fall back to a - single, unpartitioned table. ## See also