Skip to main content
On this page

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.

Audience
Data teams
Prerequisites
  • A query you can edit

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:

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
ORDER BY
3
RegionOrdersOrders (Rank)
EMEA7771
LATAM7602
AMER7283
APAC6714
null645

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 whenUseWhy
The value must stay right at any grouping, such as a ratio, or a share that a measure can work outA measureA 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 numberA custom calculationIt 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 monthA custom calculation in the queryThe 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.

ModelA window in its final SELECT isThe Windows menu
QueryA custom calculationOffered on every field. Writes CUSTOM_CALCULATION.
Kater dataset, semantic modelA dimension over the model’s own rows. See Dimensions.Offered on dimensions only. Writes DIMENSION.
Workspace rootAn error, window_not_allowedNot 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:

workspaces/shop/query_contracts/channel_rank_months.sql
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 ranked
GROUP BY
ranked.channel_rank,
ranked.channel
ORDER BY
1,
3 DESC,
2
Channel Rank in MonthChannelMonths
1email7
1store7
1paid4
1web4
2paid9

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:

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) */
CUSTOM_CALCULATION('is_top_3', rank__count_distinct__order_id <= 3) AS "Is Top 3"
FROM workspace.shop AS shop
GROUP BY
shop.region
ORDER BY
3
RegionOrdersOrders (Rank)Is Top 3
EMEA7771true
LATAM7602true
AMER7283true
APAC6714false
null645false

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 asPublished asThe compiler says
CUSTOM_CALCULATION('orders_rank', RANK() OVER (ORDER BY EVAL(shop.count_distinct__order_id) DESC))A custom calculationNothing
DIMENSION('orders_rank', RANK() OVER (…))A custom calculationWarning 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_rankA custom calculation with no field nameHint unnamed_output, with the fix Name this field as a custom calculation
CUSTOM_CALCULATION('region_upper', UPPER(shop.region))A dimensionWarning 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 groupingThe same warning, with Make it reusable with MEASURE before Declare as a dimension
  • A MEASURE over 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 with CUSTOM_CALCULATION. In a shared model, the fix declares it as a DIMENSION.
  • CUSTOM_CALCULATION outside a query is an error. The code is custom_calculation_not_allowed, and the fix declares it as a DIMENSION.
  • 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: QUALIFY for a window, HAVING for 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.

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
QUALIFY
KATER__IS_AT_MOST(KATER__RANK(EVAL(shop.count_distinct__order_id), 'desc') OVER (), 3)
ORDER BY
3
RegionOrdersOrders (Rank)
EMEA7771
LATAM7602
AMER7283

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 queryResult
q.running_sum__sum__quantity, in a SELECT that does not groupThe same custom calculation
q.running_sum__sum__quantity in a grouped SELECT, or in its GROUP BYA dimension, a grouping key
WHERE q.running_sum__sum__quantity >= 8000, or an optional filter on itA 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 itAn aggregate of the finished values
SUM or AVG of itAllowed, 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:

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

Adding up a running total counts months again

workspaces/shop/query_contracts/yearly_units.sql
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 q
GROUP BY
KATER__YEAR(q.order_month)
ORDER BY
1
Order YearRunning Units Added Up
2025-01-0134,899
2026-01-0166,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:

workspaces/shop/query_contracts/yearly_units.sql
SELECT
KATER__YEAR(q.order_month) AS "Order Year",
MAX(q.running_sum__sum__quantity) AS "Units So Far"
FROM query.monthly_units AS q
GROUP BY
KATER__YEAR(q.order_month)
ORDER BY
1
Order YearUnits So Far
2025-01-015,279
2026-01-019,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

workspaces/shop/query_contracts/units_over_8000.sql
SELECT
q.order_month,
q.running_sum__sum__quantity
FROM query.monthly_units AS q
WHERE
q.running_sum__sum__quantity >= 8000
Order MonthUnits (Running Sum)
2026-07-018,258
2026-08-018,712
2026-09-019,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:

workspaces/shop/query_contracts/region_unit_share.sql
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 shop
GROUP BY
shop.region
ORDER BY
2 DESC

Convert to a measure rewrites it as Units divided by Units with the region removed from its context:

workspaces/shop/query_contracts/region_unit_share.sql
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 shop
GROUP BY
shop.region
ORDER BY
2 DESC

Both queries give the same rows:

RegionUnitsUnits (Share of Total)
EMEA2,42126.4%
LATAM2,26024.7%
AMER2,17923.8%
APAC2,09722.9%
null1972.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 calculationBecomes
A share of the total of a sum or a count, or of a sum or difference of themThe 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 measureA 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 REMOVE drops.
  • 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

CodeCauseFix
window_not_allowedA window in a workspace root or a chart.Put it in a query, or in the shared model as a dimension.
measure_over_windowMEASURE over a window, in any model.Apply the fix: CUSTOM_CALCULATION in a query, DIMENSION in a shared model.
custom_calculation_not_allowedCUSTOM_CALCULATION outside a query.Apply the fix that declares a DIMENSION.
custom_calculation_declared_as_dimensionWarning. A DIMENSION over a window, or over a custom calculation.Apply the fix Declare with CUSTOM_CALCULATION.
dimension_declared_as_custom_calculationWarning. CUSTOM_CALCULATION over a value that reads no other rows.Apply Declare as a dimension, or Make it reusable with MEASURE.
unnamed_outputHint. An output with no field name. No other model can read it.Apply Name this field as a custom calculation.
custom_calculation_reaggregatedWarning. 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_evaluatedEVAL 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