On this page
- How it works
- Measure or custom calculation
- Where a calculation can run
- A rank that the query groups by
- What makes an output a custom calculation
- Wrappers
- Filter and sort a custom calculation
- Regions whose rank is at most 3
- Its own menu
- Read a custom calculation from another query
- Adding up a running total counts months again
- The last running total of each year
- Filter on the finished values
- Convert to a measure
- Each region’s share of Units
- What converts
- Common errors
- Next
Model Fields Custom calculations
Custom calculations
Work out values over a query's result rows, such as ranks, running totals and period comparisons, and know when a measure is the better choice.
A custom calculation is a value worked out over a query’s result rows: a rank, a running total, the row before, a share of the total, or a comparison with another period. A reader gets the values the query computed, not a recipe it can work out again at its own grouping. Only a query has custom calculations. Tests and the Sandbox have them too, because they compile as queries.
How it works
A custom calculation runs after the query groups its rows, so after WHERE, GROUP BY and HAVING. It reads the finished result rows, one row for each group.
This query of the sample shop ranks the regions by Orders:
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 shopGROUP BY shop.regionORDER BY 3| Region | Orders | Orders (Rank) |
|---|---|---|
| EMEA | 777 | 1 |
| LATAM | 760 | 2 |
| AMER | 728 | 3 |
| APAC | 671 | 4 |
| null | 64 | 5 |
The rank reads the five result rows. EMEA has the most orders of the five, so its rank is 1. The 64 orders with no region make their own row, and that row ranks last.
The value belongs to this result. Another grouping gives other rows, and other ranks. So a query that reads this one gets the finished values, and it cannot work them out again. A running total by month shows the risk: a reader cannot turn the running totals back into monthly values, and adding them up counts each month more than once. See Read a custom calculation from another query.
Measure or custom calculation
| Use this when | Use | Why |
|---|---|---|
| The value must stay right at any grouping, such as a ratio, or a share that a measure can work out | A measure | A reader works it out again from the source rows, at its own grouping. |
| The value depends on the order or the position of the result rows, such as a rank, a running total, the row before or a row number | A custom calculation | It reads the other result rows, so it has no meaning outside this result. |
| The value compares a measure with another calendar period, such as last month | A custom calculation in the query | The Compare with menu writes it. When the menu offers Convert to a measure, it can become a reusable measure. See Period comparisons. |
A share of the total can be either. Write it as a custom calculation from the Windows menu, then convert it to a measure when the menu offers that. See Convert to a measure.
Where a calculation can run
Where a window runs decides what kind of field it is.
| Model | A window in its final SELECT is | The Windows menu |
|---|---|---|
| Query | A custom calculation | Offered on every field. Writes CUSTOM_CALCULATION. |
| Kater dataset, semantic model | A dimension over the model’s own rows. See Dimensions. | Offered on dimensions only. Writes DIMENSION. |
| Workspace root | An error, window_not_allowed | Not offered. |
- A workspace root cannot run a window. Its optional filters apply before its SELECT, so the value would change with each reader’s filters. A chart cannot run a window either: put the window in the chart’s query.
- A window over a measure needs a query. A window over a measure needs a query’s grouping to mean anything. So a shared model runs windows over its dimensions only.
- A window in a CTE that the final SELECT groups by is a grouping key. So it is a dimension, in a query too. Read unchanged in a SELECT that does not group, it stays a custom calculation.
A rank that the query groups by
This query counts how many months each channel had each rank:
WITH ranked AS ( SELECT shop.order_month, shop.channel, RANK() OVER (PARTITION BY shop.order_month ORDER BY EVAL(shop.count_distinct__order_id) DESC) AS channel_rank FROM workspace.shop AS shop GROUP BY shop.order_month, shop.channel)SELECT DIMENSION('channel_rank', ranked.channel_rank) AS "Channel Rank in Month", ranked.channel, MEASURE('months', COUNT(*)) AS "Months"FROM rankedGROUP BY ranked.channel_rank, ranked.channelORDER BY 1, 3 DESC, 2| Channel Rank in Month | Channel | Months |
|---|---|---|
| 1 | 7 | |
| 1 | store | 7 |
| 1 | paid | 4 |
| 1 | web | 4 |
| 2 | paid | 9 |
The first 5 of 16 rows. The CTE ranks the four channels in each month. The final SELECT groups by that rank, so DIMENSION is the right wrapper and the compiler gives no warning. The first places add up to 22 for 21 months, because paid and web tied for first in October 2025. RANK keeps ties.
What makes an output a custom calculation
The formula decides the kind of field, not the wrapper. In a query, an output is a custom calculation when:
- Its SELECT runs a window, named or not.
- It computes a calendar comparison, even when the SQL it compiles to runs no window.
- It is built from a custom calculation in the same SELECT, such as
rank__count_distinct__order_id <= 3. - It selects another query’s custom calculation unchanged, in a SELECT that does not group.
- It is an Excel formula column,
CUSTOM_CALCULATION('margin', EXCEL('=[@revenue]-[@cost]')), whatever the formula reads. See Excel formulas.
This query flags the top three regions from the rank in the same SELECT:
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) */ CUSTOM_CALCULATION('is_top_3', rank__count_distinct__order_id <= 3) AS "Is Top 3"FROM workspace.shop AS shopGROUP BY shop.regionORDER BY 3| Region | Orders | Orders (Rank) | Is Top 3 |
|---|---|---|---|
| EMEA | 777 | 1 | true |
| LATAM | 760 | 2 | true |
| AMER | 728 | 3 | true |
| APAC | 671 | 4 | false |
| null | 64 | 5 | false |
is_top_3 reads the rank, so it depends on the result rows too. Declared as a DIMENSION, it would get the warning custom_calculation_declared_as_dimension.
Wrappers
CUSTOM_CALCULATION('name', formula) names a custom calculation, as MEASURE and DIMENSION name theirs. When a wrapper disagrees with its formula, the compiler gives a warning and publishes the formula’s kind anyway. Each fix swaps the wrapper and keeps the name and the label.
| In a query, written as | Published as | The compiler says |
|---|---|---|
CUSTOM_CALCULATION('orders_rank', RANK() OVER (ORDER BY EVAL(shop.count_distinct__order_id) DESC)) | A custom calculation | Nothing |
DIMENSION('orders_rank', RANK() OVER (…)) | A custom calculation | Warning custom_calculation_declared_as_dimension, with the fix Declare with CUSTOM_CALCULATION |
MEASURE('orders_rank', RANK() OVER (…)) | Nothing. It does not compile. | Error measure_over_window, with the fix Declare with CUSTOM_CALCULATION |
RANK() OVER (…) AS orders_rank | A custom calculation with no field name | Hint unnamed_output, with the fix Name this field as a custom calculation |
CUSTOM_CALCULATION('region_upper', UPPER(shop.region)) | A dimension | Warning dimension_declared_as_custom_calculation, with the fix Declare as a dimension |
CUSTOM_CALCULATION('orders_value', EVAL(shop.count_distinct__order_id)) | A dimension, fixed at this grouping | The same warning, with Make it reusable with MEASURE before Declare as a dimension |
- A
MEASUREover a window is an error in every model. A reader works out a measure one group at a time, so the window would see only that group. In a query, the fix declares it withCUSTOM_CALCULATION. In a shared model, the fix declares it as aDIMENSION. CUSTOM_CALCULATIONoutside a query is an error. The code iscustom_calculation_not_allowed, and the fix declares it as aDIMENSION.- A workspace root runs no window, whatever wraps it. The error is
window_not_allowed, and it has no fix.
Filter and sort a custom calculation
- Filter by this number. A number custom calculation offers six fixed conditions under Filters: Is Greater Than, Is At Least, Is Less Than, Is At Most, Is Equal To and Is Not Equal To. Kater writes the right clause for you:
QUALIFYfor a window,HAVINGfor a period comparison. - No Top N on a window. Ranking a window’s value would run a window inside a window. Use Top N on the measure instead. A period comparison runs no window, so it also offers Is In The Top and Is In The Bottom.
- Sort. Sort ascending and Sort descending write the calculation into
ORDER BY, as for any field.
Regions whose rank is at most 3
Filters › Is At Most with 3, on Orders (Rank), writes the condition in QUALIFY. QUALIFY runs after the window, so it can read the rank.
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 shopGROUP BY shop.regionQUALIFY KATER__IS_AT_MOST(KATER__RANK(EVAL(shop.count_distinct__order_id), 'desc') OVER (), 3)ORDER BY 3| Region | Orders | Orders (Rank) |
|---|---|---|
| EMEA | 777 | 1 |
| LATAM | 760 | 2 |
| AMER | 728 | 3 |
APAC and the rows with no region rank 4 and 5, so the filter drops them. Top N on the measure gives the same three rows here, with QUALIFY KATER__IS_IN_TOP(EVAL(shop.count_distinct__order_id), 3). Top N keeps ties, so it can keep more than three rows.
Its own menu
A custom calculation’s menu offers less than a field from the model, because nothing can build on it.
- Its window reopens in its form. Edit calculation opens the New calculation dialog with the partition, the order and the settings it was written with. The partition is optional.
- Sort, format and edit. Sort ascending and Sort descending. Under Format, Round, Thousands Separator, Percent and Display format. Under Edit, Change label, Classification and Duplicate. A copy is a custom calculation too.
- A number format is a comment. Round, Thousands Separator and Percent write the field’s
-- @format, not a function around the window. So the window still reopens in its form. - Filter by this number. The fixed conditions above.
- Nothing that builds on it. No Aggregations, Derive, Timeframe or new Windows, and no filters but Filter by this number. SQL cannot run a window inside another, and a condition on a calculation is a calculation too.
Read a custom calculation from another query
A query can read another query of its workspace with FROM query.<name>. It gets each custom calculation as the value that query computed. Nothing is worked out again at the reader’s grouping, and the reader’s WHERE filters the finished values.
| In the reading query | Result |
|---|---|
q.running_sum__sum__quantity, in a SELECT that does not group | The same custom calculation |
q.running_sum__sum__quantity in a grouped SELECT, or in its GROUP BY | A dimension, a grouping key |
WHERE q.running_sum__sum__quantity >= 8000, or an optional filter on it | A filter on the finished values |
A window over it, such as RANK() OVER (ORDER BY q.running_sum__sum__quantity DESC) | A new custom calculation |
MIN, MAX or COUNT of it | An aggregate of the finished values |
SUM or AVG of it | Allowed, with the warning custom_calculation_reaggregated |
EVAL(q.running_sum__sum__quantity) | The error custom_calculation_evaluated, with the fix to select it without EVAL |
The field list inserts it bare, never with EVAL. In the reading query, its menu is a dimension’s menu for its data type, without Aggregations.
The examples read this query, monthly_units, which keeps a running total of Units by month:
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 shopGROUP BY shop.order_monthORDER BY 1Adding up a running total counts months again
SELECT KATER__YEAR(q.order_month) AS "Order Year", SUM(q.running_sum__sum__quantity) AS "Running Units Added Up"FROM query.monthly_units AS qGROUP BY KATER__YEAR(q.order_month)ORDER BY 1| Order Year | Running Units Added Up |
|---|---|
| 2025-01-01 | 34,899 |
| 2026-01-01 | 66,773 |
The query runs, with the warning custom_calculation_reaggregated: “running_sum__sum__quantity is already computed by query monthly_units over its result rows, so SUM() here combines those results rather than computing it for these rows.” The shop sold 9,154 units in all, so neither number means anything. Each month’s units are in every later running total, so the sum counts them again and again.
The last running total of each year
A maximum reads the finished values as they are, with no warning:
SELECT KATER__YEAR(q.order_month) AS "Order Year", MAX(q.running_sum__sum__quantity) AS "Units So Far"FROM query.monthly_units AS qGROUP BY KATER__YEAR(q.order_month)ORDER BY 1| Order Year | Units So Far |
|---|---|
| 2025-01-01 | 5,279 |
| 2026-01-01 | 9,154 |
The running total only goes up, so its maximum is its value at the end of each year. By the end of 2025, the shop had sold 5,279 units, and 9,154 by the end of September 2026. For Units in each year, group the workspace’s Units by year instead.
Filter on the finished values
SELECT q.order_month, q.running_sum__sum__quantityFROM query.monthly_units AS qWHERE q.running_sum__sum__quantity >= 8000| Order Month | Units (Running Sum) |
|---|---|
| 2026-07-01 | 8,258 |
| 2026-08-01 | 8,712 |
| 2026-09-01 | 9,154 |
The WHERE reads the running totals that monthly_units computed over all 21 months. It does not start the running total again from July.
EVAL(q.running_sum__sum__quantity) does not compile. The compiler says: “running_sum__sum__quantity is already computed by query monthly_units over its result rows, so EVAL can’t compute it again here. Select it without EVAL.”
Convert to a measure
A custom calculation stays in its query. When a measure gives the same numbers, the calculation’s menu offers Edit › Convert to a measure. It rewrites the calculation in place as that measure, and keeps its name and its label. A reader can then work out the value at any grouping, and you can promote it.
Each region’s share of Units
Windows › Share of Total on Units writes a custom calculation:
SELECT shop.region, EVAL(shop.sum__quantity), CUSTOM_CALCULATION( 'share_of_total__sum__quantity', KATER__SHARE_OF_TOTAL(EVAL(shop.sum__quantity)) OVER () ) AS "Units (Share of Total)" /* @format percent(1) */FROM workspace.shop AS shopGROUP BY shop.regionORDER BY 2 DESCConvert to a measure rewrites it as Units divided by Units with the region removed from its context:
SELECT shop.region, EVAL(shop.sum__quantity), MEASURE( 'share_of_total__sum__quantity', CAST(EVAL(shop.sum__quantity) AS DOUBLE) / NULLIF(EVAL(shop.sum__quantity, REMOVE(shop.region)), 0) ) AS "Units (Share of Total)" /* @format percent(1) */FROM workspace.shop AS shopGROUP BY shop.regionORDER BY 2 DESCBoth queries give the same rows:
| Region | Units | Units (Share of Total) |
|---|---|---|
| EMEA | 2,421 | 26.4% |
| LATAM | 2,260 | 24.7% |
| AMER | 2,179 | 23.8% |
| APAC | 2,097 | 22.9% |
| null | 197 | 2.2% |
The window divides each row’s Units by the sum of the five rows. The measure divides each region’s Units by Units with no region in its context, which is all 9,154 units. Both give 2,421 / 9,154 = 26.4% for EMEA. Only the measure gives a right answer at another grouping too.
What converts
| Custom calculation | Becomes |
|---|---|
| A share of the total of a sum or a count, or of a sum or difference of them | The measure divided by its own EVAL, with REMOVE of the grouping keys the window does not partition by |
| A complete period comparison of a reusable measure | A calendar measure that works out the current and the earlier totals again. See Period comparisons. |
- The numbers must match. A share converts only when its partition keeps every dimension that the measure cannot be added up across. Also, no filter can read a grouping key that
REMOVEdrops. - A distinct count does not convert. Orders counts distinct order IDs, and distinct counts do not add up across groups. So a share of Orders stays a custom calculation.
- Nothing converts in a query with a join or a
HAVING. There, the result rows are not the measure’s rows. - Nothing else converts. The other windows depend on the result rows, so they never convert. That covers lag, lead, rank, row number, first and last value, delta and percent delta. It also covers running and trailing aggregations, and any window over a dimension.
- Promotion converts for you. When you promote a share of the total, Kater rewrites it as this measure first. See Promotion.
Common errors
| Code | Cause | Fix |
|---|---|---|
window_not_allowed | A window in a workspace root or a chart. | Put it in a query, or in the shared model as a dimension. |
measure_over_window | MEASURE over a window, in any model. | Apply the fix: CUSTOM_CALCULATION in a query, DIMENSION in a shared model. |
custom_calculation_not_allowed | CUSTOM_CALCULATION outside a query. | Apply the fix that declares a DIMENSION. |
custom_calculation_declared_as_dimension | Warning. A DIMENSION over a window, or over a custom calculation. | Apply the fix Declare with CUSTOM_CALCULATION. |
dimension_declared_as_custom_calculation | Warning. CUSTOM_CALCULATION over a value that reads no other rows. | Apply Declare as a dimension, or Make it reusable with MEASURE. |
unnamed_output | Hint. An output with no field name. No other model can read it. | Apply Name this field as a custom calculation. |
custom_calculation_reaggregated | Warning. A sum or an average of another query’s custom calculation. | Aggregate the field it was computed from, or select the calculation on its own. |
custom_calculation_evaluated | EVAL of another query’s custom calculation. | Select it without EVAL. |
Errors about where a window can go, such as invalid_window_placement, are on Running totals, ranks and windows.
Next
- Running totals, ranks and windows: the Windows menu, one example for each function.
- Period comparisons: the Compare with menu, against the previous period, last year or any shift you set.