Skip to main content
On this page

Model Fields Custom calculations

Running totals, ranks and windows

Add running totals, moving windows, ranks, row-to-row changes and shares of the total to a query.

Audience
Data teams
Prerequisites
  • A query you can edit

A window works out a value for each result row from other rows of the result. The Windows menu adds running totals, moving averages and ranks. It also adds the value from the row before or after, the change from the row before, and shares of the total. In a query, each one is a custom calculation: it reads the query’s result rows, after the query groups them.

How it works

A window has three parts, which SQL writes in its OVER clause:

PartWhat it doesExample
PartitionThe rows start over for each value. Without a partition, the window runs over every result row.PARTITION BY shop.region
OrderThe order the window reads the rows in.ORDER BY shop.order_month ASC
FrameThe rows around this row that count. A running total counts from the first row, and a moving average counts the last few rows.'running', or 'trailing', 3

Syntax

KATER__FUNCTION(EVAL(measure), arguments) OVER (PARTITION BY dimension ORDER BY dimension ASC)
PartWhat it doesExample
KATER__FUNCTIONThe window function. There is one for each item of the Windows menu.KATER__WINDOW_AGGREGATION
EVAL(measure)The value the window reads: a measure at the query’s grouping, or a dimension.EVAL(shop.sum__quantity)
ArgumentsThe function’s settings, after the value.'sum', 'running'
PARTITION BYOptional. The dimensions the window starts over for.PARTITION BY shop.currency
ORDER BYThe order. Rank, Row Number and Share of Total take none, because they order the rows themselves or need no order.ORDER BY shop.order_month ASC
  • Give an order. A running total, a lag, a lead, a delta and a first or last value read the rows in order. Without an order, the warehouse reads them in any order.
  • Break ties. When two rows can have the same order value, the order between them can change from run to run. For a row number, a lag or a moving window, add a second key so the order is stable. RANK keeps ties on purpose: equal values share a rank.
  • A frame counts rows, not periods. A trailing window of 3 reads this row and the two rows before it. When a month has no rows, the window reaches one more month back. To compare calendar periods, use a period comparison.

Window functions

FunctionMenu itemGivesFormat it gets
KATER__WINDOW_AGGREGATION(x, 'function', 'running')Window AggregationA running count, count_distinct, min, max, sum or avg, from the first row to this rownumber(0) for a count, else the field’s format
KATER__WINDOW_AGGREGATION(x, 'function', 'trailing', size)Window AggregationThe same over this row and the size - 1 rows before it. With no size, it is 3.The same
KATER__LAG(x, offset)LagThe value offset rows before this row. With no offset, it is 1.The field’s format
KATER__LEAD(x, offset)LeadThe value offset rows after this rowThe field’s format
KATER__FIRST_VALUE(x)First ValueThe first value of the partition, in the window’s orderThe field’s format
KATER__LAST_VALUE(x)Last ValueThe last value of the partition, in the window’s orderThe field’s format
KATER__DELTA(x)DeltaThis value minus the value in the row beforeThe field’s format
KATER__PERCENT_DELTA(x)Percent DeltaThe change from the row before, divided by the absolute value of the row beforepercent(1)
KATER__RANK(x, 'desc')RankThe position from the highest, 'desc', or from the lowest, 'asc'. Equal values share a rank.number(0)
KATER__ROW_NUMBER(x, 'desc')Row NumberThe position 1, 2, 3, with no shared positionsnumber(0)
KATER__SHARE_OF_TOTAL(x)Share of TotalThe value divided by the sum of the values in its partitionpercent(1)
  • Rank, Row Number and Share of Total take only a partition. Write OVER () or OVER (PARTITION BY …). The others take an ORDER BY and an optional PARTITION BY.
  • The menu writes the format. “The field’s format” is the format of the field the window reads. A field with no format gets number(2) for an average, or for a value that is not a whole number, and no format otherwise. A currency(currency, 2) becomes number(2) when the SELECT has no Currency dimension. See Formats, descriptions and AI context.
  • The menu names the field. A window gets a name from its function and its field, such as running_sum__sum__quantity, and a label such as “Units (Running Sum)”.

The Windows menu

Right-click a field in the field list, or on its column, and open Windows. A number offers Window Aggregation, Lag, Lead, Delta, Percent Delta, Rank, Row Number and Share of Total. A text or a date offers Lag, Lead, First Value and Last Value.

The menu on the Active Identities column with Windows open: Window Aggregation, Lag, Lead, Delta, Percent Delta, Rank, Row Number and Share of Total, with Share of Total under the pointer
The Windows menu on a number.

Each item opens the New calculation dialog, filled in. Turn it into names the window in plain words, such as Running total or Moving average. Under Settings, Along is the window’s order and Start over for each is its partition. For Share of Total, the partition is Out of the total for each. For Rank and Row Number, it is Rank within each. Select Create calculation to add the field to the query.

  • Start over for each starts filled in. The dialog fills it with the query’s other dimensions, but not its dates. So a running total of Revenue by month and currency starts over for each currency. Empty it for one window across every row.
  • Any window can be written by hand. Plain SQL such as SUM(EVAL(shop.sum__quantity)) OVER (ORDER BY shop.order_month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) is a custom calculation too. When it means the same as one of the functions above, its menu reopens it in that function’s form, here Window Aggregation. Without the ROWS frame, SQL’s default frame is not the same, so it does not reopen.

Examples

A running total

How many units has the shop sold so far, month by month?

  1. In a query grouped by Order Month, right-click Units.
  2. Select Windows › Window Aggregation.
  3. Under Turn it into, select Running total. Check that Along is Order Month.
  4. Select Create calculation.
workspaces/shop/query_contracts/monthly_units.sql
SELECT
shop.order_month,
EVAL(shop.sum__quantity),
CUSTOM_CALCULATION(
'running_sum__sum__quantity',
KATER__WINDOW_AGGREGATION(EVAL(shop.sum__quantity), 'sum', 'running') OVER (ORDER BY shop.order_month ASC)
) AS "Units (Running Sum)"
FROM workspace.shop AS shop
GROUP BY
shop.order_month
ORDER BY
1
Order MonthUnitsUnits (Running Sum)
2025-01-01455455
2025-02-01501956
2025-03-014061,362
2025-04-014761,838
2025-05-014422,280

The first 5 of 21 rows. Each row adds its month’s units to the total of the rows before it: 455 + 501 = 956. The last row, September 2026, reads 9,154, all the units the shop sold.

The shop’s monthly_revenue query declares its running total with CUSTOM_CALCULATION, as the menu does. A window declared with DIMENSION is published as a custom calculation anyway, with the warning custom_calculation_declared_as_dimension.

A moving average of the last 3 months

What is the average of this month’s units and the two months before?

  1. Right-click Units.
  2. Select Windows › Window Aggregation.
  3. Under Turn it into, select Moving average. It averages 3 rows unless you change it under Settings.
  4. Select Create calculation.
workspaces/shop/query_contracts/units_trailing_average.sql
SELECT
shop.order_month,
EVAL(shop.sum__quantity),
CUSTOM_CALCULATION(
'trailing_3_avg__sum__quantity',
KATER__WINDOW_AGGREGATION(EVAL(shop.sum__quantity), 'avg', 'trailing', 3) OVER (ORDER BY shop.order_month ASC)
) AS "Units (Trailing 3 Average)" /* @format number(2) */
FROM workspace.shop AS shop
GROUP BY
shop.order_month
ORDER BY
1
Order MonthUnitsUnits (Trailing 3 Average)
2025-01-01455455.00
2025-02-01501478.00
2025-03-01406454.00
2025-04-01476461.00
2025-05-01442441.33

The first 5 of 21 rows. The first two rows have fewer rows before them, so they average fewer months: (455 + 501) / 2 = 478 for February. From March on, each row averages three months: (501 + 406 + 476) / 3 = 461 for April. Units has no format, so the average gets number(2).

The row before and the row after

What were last month’s orders, and next month’s, beside this month’s?

  1. Right-click Orders.
  2. Select Windows › Lag. Under Turn it into, it is Value from the row before.
  3. Select Create calculation.
  4. Do the same with Windows › Lead, Value from the row after.
workspaces/shop/query_contracts/orders_lag_lead.sql
SELECT
shop.order_month,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'lag__count_distinct__order_id',
KATER__LAG(EVAL(shop.count_distinct__order_id)) OVER (ORDER BY shop.order_month ASC)
) AS "Orders (Lag)",
CUSTOM_CALCULATION(
'lead__count_distinct__order_id',
KATER__LEAD(EVAL(shop.count_distinct__order_id)) OVER (ORDER BY shop.order_month ASC)
) AS "Orders (Lead)"
FROM workspace.shop AS shop
GROUP BY
shop.order_month
ORDER BY
1
Order MonthOrdersOrders (Lag)Orders (Lead)
2025-01-01141null155
2025-02-01155141133
2025-03-01133155152
2025-04-01152133143
2025-05-01143152150

The first 5 of 21 rows. January 2025 has no row before it, so its lag is null. In the same way, the last row, September 2026, has no row after it, and its lead is null. KATER__LAG(x, 12) reads 12 rows back.

A lag reads the row before, not the month before. When a month has no rows, the row before is an earlier month, and the lag reads it as if it were last month. A period comparison reads the calendar month before, and it stays empty when that month has no rows. See Missing periods.

Each region’s top and bottom channel

Which channel brings each region the most orders, and which the fewest? First Value and Last Value read a text or a date, so this window reads Channel, in the order of Orders.

workspaces/shop/query_contracts/region_top_channel.sql
SELECT
shop.region,
shop.channel,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'first_value__channel',
KATER__FIRST_VALUE(shop.channel) OVER (PARTITION BY shop.region ORDER BY EVAL(shop.count_distinct__order_id) DESC)
) AS "Channel (First Value)",
CUSTOM_CALCULATION(
'last_value__channel',
KATER__LAST_VALUE(shop.channel) OVER (PARTITION BY shop.region ORDER BY EVAL(shop.count_distinct__order_id) DESC)
) AS "Channel (Last Value)"
FROM workspace.shop AS shop
WHERE
shop.region IN ('APAC', 'LATAM')
GROUP BY
shop.region,
shop.channel
ORDER BY
1,
3 DESC
RegionChannelOrdersChannel (First Value)Channel (Last Value)
APACemail178emailpaid
APACstore174emailpaid
APACweb170emailpaid
APACpaid149emailpaid
LATAMpaid201paidweb
LATAMstore199paidweb
LATAMemail186paidweb
LATAMweb174paidweb

The window starts over for each region and reads its channels from the most orders to the fewest. In APAC, email comes first and paid comes last. Last Value reads the whole partition, not only the rows up to this row, so every row of a region shows the same value.

The change from the row before

How many more orders did each month have than the month before, and by what percent?

  1. Right-click Orders.
  2. Select Windows › Delta. Under Turn it into, it is Change from the row before.
  3. Select Create calculation.
  4. Do the same with Windows › Percent Delta, % change from the row before.
workspaces/shop/query_contracts/orders_month_change.sql
SELECT
shop.order_month,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'delta__count_distinct__order_id',
KATER__DELTA(EVAL(shop.count_distinct__order_id)) OVER (ORDER BY shop.order_month ASC)
) AS "Orders (Delta)",
CUSTOM_CALCULATION(
'percent_delta__count_distinct__order_id',
KATER__PERCENT_DELTA(EVAL(shop.count_distinct__order_id)) OVER (ORDER BY shop.order_month ASC)
) AS "Orders (Percent Delta)" /* @format percent(1) */
FROM workspace.shop AS shop
GROUP BY
shop.order_month
ORDER BY
1
Order MonthOrdersOrders (Delta)Orders (Percent Delta)
2025-01-01141nullnull
2025-02-01155149.9%
2025-03-01133-22-14.2%
2025-04-011521914.3%
2025-05-01143-9-5.9%

The first 5 of 21 rows. February had 14 more orders than January, and 14 / 141 = 9.9%. January has no row before it, so both values are null. Percent Delta divides by the absolute value of the row before, so a rise from a negative value is positive. A row before that is 0 or null gives null. Percent Delta is a fraction, so never multiply it by 100: percent(1) shows 0.099 as 9.9%.

Rank and Row Number

How do the channels rank by orders in September 2026?

  1. Right-click Orders.
  2. Select Windows › Rank. Under Settings, #1 is the is Biggest.
  3. Select Create calculation.
  4. Do the same with Windows › Row Number.
workspaces/shop/query_contracts/channel_rank_september.sql
SELECT
shop.channel,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'rank__count_distinct__order_id',
KATER__RANK(EVAL(shop.count_distinct__order_id), 'desc') OVER ()
) AS "Orders (Rank)", /* @format number(0) */
CUSTOM_CALCULATION(
'row_number__count_distinct__order_id',
KATER__ROW_NUMBER(EVAL(shop.count_distinct__order_id), 'desc') OVER ()
) AS "Orders (Row Number)" /* @format number(0) */
FROM workspace.shop AS shop
WHERE
shop.order_date BETWEEN '2026-09-01' AND '2026-09-30'
GROUP BY
shop.channel
ORDER BY
3,
4
ChannelOrdersOrders (Rank)Orders (Row Number)
paid4011
web3322
email3323
store3144

Web and email both have 33 orders. RANK gives both of them 2, and the next channel gets 4. ROW_NUMBER gives them 2 and 3, in an order the warehouse sets, so the two can change places from run to run. For a stable order, write the row number in plain SQL with a second key, such as ROW_NUMBER() OVER (ORDER BY EVAL(shop.count_distinct__order_id) DESC, shop.channel).

Each region’s share of all orders

What share of the shop’s orders does each region have?

  1. Right-click Orders.
  2. Select Windows › Share of Total.
  3. Leave Out of the total for each empty, for one total across every row.
  4. Select Create calculation.
workspaces/shop/query_contracts/region_order_share.sql
SELECT
shop.region,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'share_of_total__count_distinct__order_id',
KATER__SHARE_OF_TOTAL(EVAL(shop.count_distinct__order_id)) OVER ()
) AS "Orders (Share of Total)" /* @format percent(1) */
FROM workspace.shop AS shop
GROUP BY
shop.region
ORDER BY
2 DESC
RegionOrdersOrders (Share of Total)
EMEA77725.9%
LATAM76025.3%
AMER72824.3%
APAC67122.4%
null642.1%

Each row’s orders are divided by the sum of the five rows, 3,000 orders. EMEA has 777 / 3,000 = 25.9%. A share of a sum or a count can become a measure. Orders is a distinct count, so this one stays a custom calculation. See Convert to a measure.

Each channel’s share within its region

What share of each region’s orders comes from each channel? A partition makes each region’s channels add up to 100% on their own.

  1. In a query grouped by Region and Channel, right-click Orders.
  2. Select Windows › Share of Total.
  3. Set Out of the total for each to Region.
  4. Select Create calculation.
workspaces/shop/query_contracts/channel_share_by_region.sql
SELECT
shop.region,
shop.channel,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'share_of_total__count_distinct__order_id',
KATER__SHARE_OF_TOTAL(EVAL(shop.count_distinct__order_id)) OVER (PARTITION BY shop.region)
) AS "Orders (Share of Total)" /* @format percent(1) */
FROM workspace.shop AS shop
WHERE
shop.region IN ('AMER', 'EMEA')
GROUP BY
shop.region,
shop.channel
ORDER BY
1,
3 DESC
RegionChannelOrdersOrders (Share of Total)
AMERpaid19727.1%
AMERemail19126.2%
AMERstore18024.7%
AMERweb16022.0%
EMEApaid20426.3%
EMEAemail20226.0%
EMEAstore18623.9%
EMEAweb18523.8%

AMER has 728 orders, so paid has 197 / 728 = 27.1%. The WHERE keeps two regions so the result fits here. WHERE runs before the window, so the window sees only the rows the query keeps.

A running total of Revenue for each currency

Revenue cannot be added up across currencies. See Measures you cannot add up. So a running total of Revenue must start over for each currency.

  1. In a query grouped by Order Month and Currency, right-click Revenue.
  2. Select Windows › Window Aggregation, and under Turn it into, select Running total.
  3. Check that Start over for each holds Currency. The dialog puts it there.
  4. Select Create calculation.
workspaces/shop/query_contracts/revenue_running_total.sql
SELECT
KATER__MONTH(shop.order_date) AS "Order Month",
shop.currency,
EVAL(shop.sum__amount),
CUSTOM_CALCULATION(
'running_sum__sum__amount',
KATER__WINDOW_AGGREGATION(EVAL(shop.sum__amount), 'sum', 'running') OVER (PARTITION BY shop.currency ORDER BY KATER__MONTH(shop.order_date) ASC)
) AS "Revenue (Running Sum)" /* @format currency(currency, 2) */
FROM workspace.shop AS shop
GROUP BY
KATER__MONTH(shop.order_date),
shop.currency
ORDER BY
2,
1
Order MonthCurrencyRevenueRevenue (Running Sum)
2025-01-01EUR€4,961.67€4,961.67
2025-02-01EUR€4,727.40€9,689.07
2026-09-01EUR€5,353.49€84,371.35
2025-01-01USD$16,445.68$16,445.68
2025-02-01USD$17,070.42$33,516.10
2026-09-01USD$17,693.37$370,665.44

Six of the 42 rows. The euro total ends at €84,371.35, all of EMEA’s euro revenue. The dollar total ends at $370,665.44, the dollar revenue of every region. Without PARTITION BY shop.currency, the compiler refuses the query with not_additive_across: “Revenue cannot be added up across Currency, so a total of it along the rows has to start over for each Currency. Add Currency to Start over for each (its PARTITION BY), or filter Currency to one value.”

Where a window cannot go

WHERE, GROUP BY, HAVING and JOIN conditions run before the windows. So a window cannot be in them, and they cannot read a field of the same SELECT that runs a window.

ClauseA window in itA field of the same SELECT that runs a windowWrite instead
WHEREinvalid_window_placementsibling_not_visible_at_stageFilter it in QUALIFY, or compute the window in a CTE and use its column.
GROUP BYinvalid_window_placementinvalid_grouping_fieldCompute the window in a CTE and group by its column.
HAVINGinvalid_window_placementsibling_not_visible_at_stageFilter it in QUALIFY.
A JOIN conditioninvalid_window_placementsibling_not_visible_at_stageCompute the window in a CTE and join on its column.

A rank filtered in HAVING

This query tries to keep the top three regions in HAVING:

workspaces/shop/query_contracts/region_rank.sql
SELECT
shop.region,
EVAL(shop.count_distinct__order_id),
CUSTOM_CALCULATION(
'rank__count_distinct__order_id',
KATER__RANK(EVAL(shop.count_distinct__order_id), 'desc') OVER ()
) AS "Orders (Rank)" /* @format number(0) */
FROM workspace.shop AS shop
GROUP BY
shop.region
HAVING
rank__count_distinct__order_id <= 3

The compiler refuses it with sibling_not_visible_at_stage: “HAVING can’t use ‘rank__count_distinct__order_id’, a field declared in this SELECT, because its window is computed after HAVING. Filter on it in QUALIFY instead.” QUALIFY runs after the window. Filters › Is At Most on the rank writes it there for you. See Regions whose rank is at most 3.

Common errors

CodeCauseFix
invalid_window_placementA window in WHERE, GROUP BY, HAVING or a JOIN condition.Filter it in QUALIFY, or compute it in a CTE first.
sibling_not_visible_at_stageWHERE or HAVING reads a field of the same SELECT that runs a window.Filter it in QUALIFY.
invalid_grouping_fieldGROUP BY reads a field of the same SELECT that runs a window.Compute the window in a CTE and group by its column.
not_additive_acrossA running total or a share of a measure such as Revenue runs across currencies.Add Currency to Start over for each or Out of the total for each, its PARTITION BY. Or filter it to one value.
measure_over_windowMEASURE over a window.Declare it with CUSTOM_CALCULATION. See Wrappers.
custom_calculation_declared_as_dimensionWarning. A DIMENSION over a window in a query.Apply the fix Declare with CUSTOM_CALCULATION.