Skip to main content
On this page

Model Fields Measures

Measures you cannot add up

Mark a measure that is wrong when added across a dimension, such as revenue across currencies, and let the compiler hold every reader to the rule.

Audience
Data teams
Prerequisites
  • A measure you can edit, or a query that reads one

Euros added to dollars give a number with no meaning. NOT_ADDITIVE_ACROSS(currency) on a measure says so. Then every query of the measure must hold the currency to one value per result row. The compiler refuses a query that does not. The rule stays with the measure in every model, query and measure that reads it.

How it works

In the sample shop, Revenue (sum__amount) carries NOT_ADDITIVE_ACROSS(currency). Only EMEA has orders in two currencies:

RegionCurrencyRevenue
EMEAEUR€84,371.35
EMEAUSD$36,129.01

One row for EMEA would add 84,371.35 euros to 36,129.01 dollars. The rule stops that. Each result row must have one currency, and there are three ways to give it one:

WayYou writeExample
Group by the dimensionGROUP BY shop.region, shop.currencyGroup by the currency
Filter it to one valueWHERE shop.currency = 'USD'Filter the currency to one value
Set it in an EVALEVAL(shop.sum__amount, SET(shop.currency = 'USD'))Set the currency in an EVAL

A smaller unit counts too, when a relationship says that it identifies the dimension. See A smaller unit that identifies the currency.

Use the rule when a measure is right only if each group has one value of a dimension. Revenue summed across currencies is one example. A daily reach summed across days is another, because the same person counts on each day.

Syntax

MEASURE('name', formula, NOT_ADDITIVE_ACROSS(dimension, ...)) AS "Label"
PartWhat it doesExample
NOT_ADDITIVE_ACROSS(...)The last argument of MEASURE. It names the dimensions the measure cannot be added up across.NOT_ADDITIVE_ACROSS(currency)
Each dimensionA dimension of the same model, named by its field name, not its label. A new label changes nothing.currency
Several dimensionsEach result row must hold every one of them to one value.NOT_ADDITIVE_ACROSS(currency, region)

This is Revenue in the orders Kater dataset:

modeling/kater_objects/datasets/orders.sql
-- @description Order amount before discounts.
MEASURE('sum__amount', KATER__SUM(orders.amount), NOT_ADDITIVE_ACROSS(currency)) AS "Revenue", /* @format currency(currency, 2) */

Add the rule to a measure

The field menu of a query writes the rule on a measure that the query declares. This query declares Large Order Revenue, the revenue from orders with an amount of 100 or more.

  1. In the field list, right-click Large Order Revenue under Added in this query.
  2. Under Edit, select Not additive across.
  3. Select currency. The form opens with the rule the measure has now. Clear every dimension to take the rule away.
workspaces/shop/query_contracts/large_order_revenue.sql
SELECT
shop.region,
shop.currency,
-- @format currency(currency, 2)
MEASURE(
'large_order_revenue',
SUM(CASE WHEN shop.amount >= 100 THEN shop.amount END),
NOT_ADDITIVE_ACROSS(currency)
) AS "Large Order Revenue"
FROM workspace.shop AS shop
GROUP BY
shop.region,
shop.currency
ORDER BY
shop.region,
shop.currency
RegionCurrencyLarge Order Revenue
AMERUSD$85,100.10
APACUSD$85,026.80
EMEAEUR€69,668.10
EMEAUSD$30,088.30
LATAMUSD$96,984.10
nullUSD$8,107.60

In a Kater dataset or a semantic model, the field menu has no Edit group. Write the rule in the SQL there. Your agent and the terminal use the same operation, field.not_additive_across. kater operation list PATH --field FIELD shows it for a measure that the file declares.

Examples

A query the rule refuses

Revenue by region, with nothing that holds the currency.

workspaces/shop/query_contracts/revenue_by_region.sql
SELECT
shop.region,
shop.sum__amount
FROM workspace.shop AS shop
GROUP BY
shop.region

The compiler refuses the query with not_additive_across: “Revenue cannot be added up across Currency. Group by Currency or filter it to one value.” The EMEA row would add euros to dollars. A total with no grouping is refused for the same reason.

The quick fix, Group by Currency, adds shop.currency to the SELECT, before Revenue, and to the GROUP BY. The next example is the result.

Group by the currency

Revenue by region and currency.

workspaces/shop/query_contracts/revenue_by_region.sql
SELECT
shop.region,
shop.currency,
shop.sum__amount
FROM workspace.shop AS shop
GROUP BY
shop.region,
shop.currency
ORDER BY
shop.region,
shop.currency
RegionCurrencyRevenue
AMERUSD$105,459.33
APACUSD$102,849.35
EMEAEUR€84,371.35
EMEAUSD$36,129.01
LATAMUSD$116,577.89
nullUSD$9,649.86

EMEA now has one row for each currency. Each row shows its own currency symbol, from the measure’s format.

Filter the currency to one value

Revenue by region, in dollars only.

workspaces/shop/query_contracts/usd_revenue_by_region.sql
SELECT
shop.region,
shop.sum__amount
FROM workspace.shop AS shop
WHERE
shop.currency = 'USD'
GROUP BY
shop.region
ORDER BY
shop.region
RegionRevenue
AMER$105,459.33
APAC$102,849.35
EMEA$36,129.01
LATAM$116,577.89
null$9,649.86

The equality holds every row to USD, so the query can group by region alone. The result has no Currency column, but the format shows dollars, because the filter names the currency. KATER__IS_EQUAL_TO(shop.currency, 'EUR') counts too. A filter to two values, such as shop.currency IN ('USD', 'EUR'), does not, and the compiler refuses it.

Set the currency in an EVAL

Revenue by region, with one column for each currency.

workspaces/shop/query_contracts/revenue_by_currency_columns.sql
SELECT
shop.region,
-- @format currency('USD', 2)
MEASURE('usd_revenue', EVAL(shop.sum__amount, SET(shop.currency = 'USD'))) AS "USD Revenue",
-- @format currency('EUR', 2)
MEASURE('eur_revenue', EVAL(shop.sum__amount, SET(shop.currency = 'EUR'))) AS "EUR Revenue"
FROM workspace.shop AS shop
GROUP BY
shop.region
ORDER BY
shop.region
RegionUSD RevenueEUR Revenue
AMER$105,459.33null
APAC$102,849.35null
EMEA$36,129.01€84,371.35
LATAM$116,577.89null
null$9,649.86null

Each SET holds one evaluation to one currency, and the rows stay one per region. Only EMEA has orders in euros, so the other regions show null for EUR Revenue. See Change a measure’s context for SET and the other modifiers.

A measure built on Revenue keeps the rule

Revenue per Customer, from the shop’s region_mix query, by region group alone.

workspaces/shop/query_contracts/revenue_per_customer_by_region_group.sql
SELECT
shop.region_group,
MEASURE(
'revenue_per_customer',
EVAL(shop.sum__amount) / NULLIF(EVAL(shop.count_distinct__customer_id), 0)
) AS "Revenue per Customer"
FROM workspace.shop AS shop
GROUP BY
shop.region_group

The compiler refuses it with not_additive_across: “Revenue cannot be added up across Currency. Group by Currency or filter it to one value.” The new measure evaluates Revenue, so it has Revenue’s rule. Group by the currency too, and the query runs:

workspaces/shop/query_contracts/revenue_per_customer_by_region_group.sql
SELECT
shop.region_group,
shop.currency,
-- @format currency(currency, 2)
MEASURE(
'revenue_per_customer',
EVAL(shop.sum__amount) / NULLIF(EVAL(shop.count_distinct__customer_id), 0)
) AS "Revenue per Customer"
FROM workspace.shop AS shop
GROUP BY
shop.region_group,
shop.currency
ORDER BY
shop.region_group,
shop.currency
Region GroupCurrencyRevenue per Customer
AmericasUSD$454.57
EuropeEUR€396.11
EuropeUSD$249.17
Rest of WorldUSD$954.49

The rule also reaches a query that reads region_mix. This query groups its Revenue per Customer by region group alone:

workspaces/shop/query_contracts/read_region_mix.sql
SELECT
q.region_group,
q.revenue_per_customer
FROM query.region_mix AS q
GROUP BY
q.region_group

The compiler refuses it: “Revenue per Customer cannot be added up across Currency. Group by Currency or filter it to one value.” A pass-through keeps the rule in the same way. The workspace passes Revenue on from semantic.shop, which passes it on from the orders dataset. Each reader meets the rule.

A model that holds the currency passes Revenue on with no rule

A query with an always-applied equality on the currency. Call it usd_regions.

workspaces/shop/query_contracts/usd_regions.sql
SELECT
shop.region,
shop.sum__amount
FROM workspace.shop AS shop
WHERE
shop.currency = 'USD'
GROUP BY
shop.region

Every row of usd_regions is in dollars, so the Revenue it passes on has no rule left. A query that reads it can total Revenue with no currency at all:

workspaces/shop/query_contracts/read_usd_regions.sql
SELECT
usd.sum__amount
FROM query.usd_regions AS usd
Revenue
370,665.44

This is the sum of the five dollar rows. The result has no Currency column, so the number shows no currency symbol. See Pair the rule with a format. A workspace root with the same WHERE passes Revenue on in the same way. A REQUIRED_FILTER with an equality, such as KATER__IS_EQUAL_TO(shop.currency, 'USD'), counts too, because it always applies.

An optional filter does not count

A query with the equality as an optional filter. Call it usd_regions_optional. It groups by the currency too, because a reader can switch the filter off.

workspaces/shop/query_contracts/usd_regions_optional.sql
SELECT
shop.region,
shop.currency,
shop.sum__amount
FROM workspace.shop AS shop
WHERE
OPTIONAL_FILTER('is_usd', KATER__IS_EQUAL_TO(shop.currency, 'USD'), label='Currency is USD')
GROUP BY
shop.region,
shop.currency

With the filter off, its rows hold both currencies:

RegionCurrencyRevenue
AMERUSD$105,459.33
APACUSD$102,849.35
EMEAEUR€84,371.35
EMEAUSD$36,129.01
LATAMUSD$116,577.89
nullUSD$9,649.86

So the Revenue it passes on keeps the rule. A reader that totals it with no currency is refused:

workspaces/shop/query_contracts/read_usd_regions_optional.sql
SELECT
usd.sum__amount
FROM query.usd_regions_optional AS usd

The compiler refuses it with not_additive_across: “Revenue cannot be added up across Currency. Group by Currency or filter it to one value.”

A smaller unit that identifies the currency

Revenue by order, in a model where a relationship says each order has one currency.

This semantic model starts from order events and joins each event to its order, many to one:

modeling/kater_objects/semantic_models/events.sql
SELECT
e."event id",
e.order_id,
e.eventtype,
o.currency,
o.sum__amount
FROM kater_dataset.order_events AS e
-- @label Order
LEFT JOIN RELATIONSHIP('order', kater_dataset.orders AS o ON e.order_id = o.order_id, 'many_to_one')

A query of its workspace, events, groups Revenue by order ID alone:

workspaces/events/query_contracts/revenue_by_order.sql
SELECT
events.order_id,
events.sum__amount
FROM workspace.events AS events
GROUP BY
events.order_id
ORDER BY
events.order_id
LIMIT 3
Order IDRevenue
O0000079.96
O0000139.98
O0000237.50

The join is many to one on order_id, so one order ID gives one order, and one currency. The result has no Currency column, so the numbers show no symbol. Group the same model by event type, and the compiler refuses it: “Revenue cannot be added up across Currency. Group by Currency or by Order ID, which identifies it, or filter it to one value.”

The compiler reads this from the relationship, never from the data. In semantic.shop, each order also has one currency. No relationship says so, and grouping Revenue by shop.order_id alone is refused. The model must publish the join’s key columns, here e.order_id.

A running total starts over for each currency

Revenue by month, with a running total for each currency.

workspaces/shop/query_contracts/running_revenue_by_currency.sql
SELECT
KATER__MONTH(shop.order_month),
shop.currency,
shop.sum__amount,
CUSTOM_CALCULATION(
'running_sum__sum__amount',
KATER__WINDOW_AGGREGATION(shop.sum__amount, 'sum', 'running') OVER (PARTITION BY shop.currency ORDER BY KATER__MONTH(shop.order_month) ASC)
) AS "Revenue (Running Sum)" /* @format currency(currency, 2) */
FROM workspace.shop AS shop
GROUP BY
KATER__MONTH(shop.order_month),
shop.currency
ORDER BY
KATER__MONTH(shop.order_month),
shop.currency

The first 6 of 42 rows:

Order MonthCurrencyRevenueRevenue (Running Sum)
2025-01-01EUR€4,961.67€4,961.67
2025-01-01USD$16,445.68$16,445.68
2025-02-01EUR€4,727.40€9,689.07
2025-02-01USD$17,070.42$33,516.10
2025-03-01EUR€4,254.65€13,943.72
2025-03-01USD$16,135.91$49,652.01

A running total adds the rows before it. PARTITION BY shop.currency starts it over for each currency, so euros and dollars never meet. The shop’s monthly_revenue query partitions its running total in the same way. In the new-field form, the partition is Start over for each.

Take the partition out, and 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.” See Running totals, ranks and windows.

A share of total is out of each currency’s total

Each region’s share of Revenue in its own currency.

workspaces/shop/query_contracts/revenue_share_by_currency.sql
SELECT
shop.region,
shop.currency,
shop.sum__amount,
CUSTOM_CALCULATION(
'share_of_total__sum__amount',
KATER__SHARE_OF_TOTAL(shop.sum__amount) OVER (PARTITION BY shop.currency)
) AS "Revenue (Share of Total)" /* @format percent(1) */
FROM workspace.shop AS shop
GROUP BY
shop.region,
shop.currency
ORDER BY
shop.currency,
shop.region
RegionCurrencyRevenueRevenue (Share of Total)
EMEAEUR€84,371.35100.0%
AMERUSD$105,459.3328.5%
APACUSD$102,849.3527.7%
EMEAUSD$36,129.019.7%
LATAMUSD$116,577.8931.5%
nullUSD$9,649.862.6%

The dollar shares add up to 100%, and EMEA has all the euros. In the new-field form, the partition is Out of the total for each. With OVER (), the compiler refuses the query: “Revenue cannot be added up across Currency, so a share or total of it has to be out of the total for each Currency. Add Currency to Out of the total for each (its PARTITION BY), or filter Currency to one value.”

Top N within each currency

The two regions with the most Revenue in each currency.

workspaces/shop/query_contracts/top_regions_by_currency.sql
SELECT
shop.region,
shop.currency,
EVAL(shop.sum__amount)
FROM workspace.shop AS shop
GROUP BY
shop.region,
shop.currency
QUALIFY
KATER__IS_IN_TOP(EVAL(shop.sum__amount), 2) OVER (PARTITION BY shop.currency)
ORDER BY
shop.currency,
3 DESC
RegionCurrencyRevenue
EMEAEUR€84,371.35
LATAMUSD$116,577.89
AMERUSD$105,459.33

The partition ranks the euro rows and the dollar rows on their own. EMEA is the only euro row, so it is the only one kept there. A rank adds nothing up, so the compiler does not ask for the partition. Without it, euro rows rank among dollar rows, as in the shop’s top_regions query.

A modifier cannot take the currency out

Revenue for each region and currency, with the currency removed from one evaluation.

workspaces/shop/query_contracts/revenue_without_currency.sql
SELECT
shop.region,
shop.currency,
EVAL(shop.sum__amount, REMOVE(shop.currency))
FROM workspace.shop AS shop
GROUP BY
shop.region,
shop.currency

The compiler refuses it with not_additive_across: “Revenue cannot be added up across Currency, so REMOVE cannot take Currency out. Keep Currency, or SET it to one value.” CLEAR() and SET_CUSTOM() drop the whole grouping, so the compiler refuses them too. That holds in a query and inside another MEASURE. REMOVE of another dimension is allowed. EVAL(shop.sum__amount, REMOVE(shop.region)) gives each region’s currency total.

Pair the rule with a format

currency(currency, 2) shows each row in its own currency. It reads the symbol from the Currency column of the same result. The rule makes sure each row has one currency to read. Write it above the measure, or after it as the orders dataset does:

modeling/kater_objects/datasets/orders.sql
-- @format currency(currency, 2)
MEASURE('sum__amount', KATER__SUM(orders.amount), NOT_ADDITIVE_ACROSS(currency)) AS "Revenue",
  • The column is in the result. Each row shows its own symbol, such as $105,459.33 and €84,371.35.
  • A filter holds the currency to one value. The result shows that currency’s symbol, though the column is not there.
  • Neither. The result shows the number with no symbol, such as 79.96.

See Formats, descriptions and AI context.

Keep the rule with a test

A shared test can keep a refused query, so the rule is never lost. If someone takes the rule off Revenue, the test fails. Save the refused query as revenue_by_region.sql, with FROM semantic.shop AS shop in place of the workspace, then run:

Terminal window
kater new test revenue_needs_currency --model semantic.shop \
--sql-file revenue_by_region.sql --refused --shared

It compiles the SQL and keeps the code the compiler refuses it with. The test goes in the shared tests of the model, modeling/kater_objects/tests/shop.sql:

modeling/kater_objects/tests/shop.sql
---
name: revenue_needs_currency
model_type: test
expect:
refused: not_additive_across
---
SELECT
shop.region,
shop.sum__amount
FROM semantic.shop AS shop
GROUP BY
shop.region

The readiness check Every rule pinned asks for one such test for each rule. See Tests.

Where you see the rule

A measure’s definition card shows the rule under Inputs, as Required. Open the Definitions view, or run kater model explain sum__amount --relation semantic.shop. Revenue’s card says:

Inputs
Group or filter byany of 16 dimensions
Requiredone Currency per result row: group by it or filter it to one value
Not allowednone

Rules

  • The rule goes on a measure. Only a MEASURE takes NOT_ADDITIVE_ACROSS, as its last argument.
  • Name dimensions by field name. Write currency, not the label. A label can change, and the rule still holds.
  • One value for each result row. Group by the dimension, filter it with an equality to one value, or SET it in the EVAL. A filter to several values does not count.
  • Every reader meets the rule. A pass-through keeps it, and so does a measure built on the measure. A query that reads another query meets it too.
  • An always-applied equality ends the rule. A WHERE or REQUIRED_FILTER that holds the dimension to one value passes the measure on with no rule. An optional filter does not.
  • A relationship can stand in for the dimension. A many to one join matches on a key. Grouping by that key holds the joined side’s columns to one value.
  • A sum along the rows keeps the dimension in its partition. Running totals and shares start over, or are out of the total, for each value.
  • A distinct count needs no rule. Customers by region add up to 976, and the shop has 240 customers. Kater counts Customers again from the rows at each grouping, and never adds the regions’ values.

Common errors

CodeCauseFix
not_additive_acrossA query adds a measure up across a dimension its rule names. A grouping, a running total or a share leaves the dimension out, or REMOVE, CLEAR() or SET_CUSTOM() drops it.Group by the dimension, filter it to one value, or SET it. Apply the quick fix Group by the dimension. In a window, add the dimension to its PARTITION BY.
not_additive_acrossThe measure comes through a model that does not publish the dimension, and nothing holds it to one value.Read the measure from a model that publishes the dimension, or from one that filters it to one value.
invalid_not_additive_acrossThe rule names a field the model does not have, a label, or a measure.Name a dimension of the same model by its field name, such as currency.
invalid_field_declarationNOT_ADDITIVE_ACROSS sits on a DIMENSION or a CUSTOM_CALCULATION.Remove it. A dimension has one value per row, so there is nothing to add up.