On this page
- How it works
- Syntax
- Add the rule to a measure
- Examples
- A query the rule refuses
- Group by the currency
- Filter the currency to one value
- Set the currency in an EVAL
- A measure built on Revenue keeps the rule
- A model that holds the currency passes Revenue on with no rule
- An optional filter does not count
- A smaller unit that identifies the currency
- A running total starts over for each currency
- A share of total is out of each currency’s total
- Top N within each currency
- A modifier cannot take the currency out
- Pair the rule with a format
- Keep the rule with a test
- Where you see the rule
- Rules
- Common errors
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.
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:
| Region | Currency | Revenue |
|---|---|---|
| EMEA | EUR | €84,371.35 |
| EMEA | USD | $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:
| Way | You write | Example |
|---|---|---|
| Group by the dimension | GROUP BY shop.region, shop.currency | Group by the currency |
| Filter it to one value | WHERE shop.currency = 'USD' | Filter the currency to one value |
Set it in an EVAL | EVAL(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"| Part | What it does | Example |
|---|---|---|
NOT_ADDITIVE_ACROSS(...) | The last argument of MEASURE. It names the dimensions the measure cannot be added up across. | NOT_ADDITIVE_ACROSS(currency) |
| Each dimension | A dimension of the same model, named by its field name, not its label. A new label changes nothing. | currency |
| Several dimensions | Each result row must hold every one of them to one value. | NOT_ADDITIVE_ACROSS(currency, region) |
This is Revenue in the orders Kater dataset:
-- @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.
- In the field list, right-click Large Order Revenue under Added in this query.
- Under Edit, select Not additive across.
- Select
currency. The form opens with the rule the measure has now. Clear every dimension to take the rule away.
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 shopGROUP BY shop.region, shop.currencyORDER BY shop.region, shop.currency| Region | Currency | Large Order Revenue |
|---|---|---|
| AMER | USD | $85,100.10 |
| APAC | USD | $85,026.80 |
| EMEA | EUR | €69,668.10 |
| EMEA | USD | $30,088.30 |
| LATAM | USD | $96,984.10 |
| null | USD | $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.
SELECT shop.region, shop.sum__amountFROM workspace.shop AS shopGROUP BY shop.regionThe 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.
SELECT shop.region, shop.currency, shop.sum__amountFROM workspace.shop AS shopGROUP BY shop.region, shop.currencyORDER BY shop.region, shop.currency| Region | Currency | Revenue |
|---|---|---|
| AMER | USD | $105,459.33 |
| APAC | USD | $102,849.35 |
| EMEA | EUR | €84,371.35 |
| EMEA | USD | $36,129.01 |
| LATAM | USD | $116,577.89 |
| null | USD | $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.
SELECT shop.region, shop.sum__amountFROM workspace.shop AS shopWHERE shop.currency = 'USD'GROUP BY shop.regionORDER BY shop.region| Region | Revenue |
|---|---|
| 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.
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 shopGROUP BY shop.regionORDER BY shop.region| Region | USD Revenue | EUR Revenue |
|---|---|---|
| AMER | $105,459.33 | null |
| APAC | $102,849.35 | null |
| EMEA | $36,129.01 | €84,371.35 |
| LATAM | $116,577.89 | null |
| null | $9,649.86 | null |
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.
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 shopGROUP BY shop.region_groupThe 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:
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 shopGROUP BY shop.region_group, shop.currencyORDER BY shop.region_group, shop.currency| Region Group | Currency | Revenue per Customer |
|---|---|---|
| Americas | USD | $454.57 |
| Europe | EUR | €396.11 |
| Europe | USD | $249.17 |
| Rest of World | USD | $954.49 |
The rule also reaches a query that reads region_mix. This query groups its Revenue per Customer by region group alone:
SELECT q.region_group, q.revenue_per_customerFROM query.region_mix AS qGROUP BY q.region_groupThe 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.
SELECT shop.region, shop.sum__amountFROM workspace.shop AS shopWHERE shop.currency = 'USD'GROUP BY shop.regionEvery 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:
SELECT usd.sum__amountFROM 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.
SELECT shop.region, shop.currency, shop.sum__amountFROM workspace.shop AS shopWHERE OPTIONAL_FILTER('is_usd', KATER__IS_EQUAL_TO(shop.currency, 'USD'), label='Currency is USD')GROUP BY shop.region, shop.currencyWith the filter off, its rows hold both currencies:
| Region | Currency | Revenue |
|---|---|---|
| AMER | USD | $105,459.33 |
| APAC | USD | $102,849.35 |
| EMEA | EUR | €84,371.35 |
| EMEA | USD | $36,129.01 |
| LATAM | USD | $116,577.89 |
| null | USD | $9,649.86 |
So the Revenue it passes on keeps the rule. A reader that totals it with no currency is refused:
SELECT usd.sum__amountFROM query.usd_regions_optional AS usdThe 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:
SELECT e."event id", e.order_id, e.eventtype, o.currency, o.sum__amountFROM kater_dataset.order_events AS e-- @label OrderLEFT 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:
SELECT events.order_id, events.sum__amountFROM workspace.events AS eventsGROUP BY events.order_idORDER BY events.order_idLIMIT 3| Order ID | Revenue |
|---|---|
| O00000 | 79.96 |
| O00001 | 39.98 |
| O00002 | 37.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.
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 shopGROUP BY KATER__MONTH(shop.order_month), shop.currencyORDER BY KATER__MONTH(shop.order_month), shop.currencyThe first 6 of 42 rows:
| Order Month | Currency | Revenue | Revenue (Running Sum) |
|---|---|---|---|
| 2025-01-01 | EUR | €4,961.67 | €4,961.67 |
| 2025-01-01 | USD | $16,445.68 | $16,445.68 |
| 2025-02-01 | EUR | €4,727.40 | €9,689.07 |
| 2025-02-01 | USD | $17,070.42 | $33,516.10 |
| 2025-03-01 | EUR | €4,254.65 | €13,943.72 |
| 2025-03-01 | USD | $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.
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 shopGROUP BY shop.region, shop.currencyORDER BY shop.currency, shop.region| Region | Currency | Revenue | Revenue (Share of Total) |
|---|---|---|---|
| EMEA | EUR | €84,371.35 | 100.0% |
| AMER | USD | $105,459.33 | 28.5% |
| APAC | USD | $102,849.35 | 27.7% |
| EMEA | USD | $36,129.01 | 9.7% |
| LATAM | USD | $116,577.89 | 31.5% |
| null | USD | $9,649.86 | 2.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.
SELECT shop.region, shop.currency, EVAL(shop.sum__amount)FROM workspace.shop AS shopGROUP BY shop.region, shop.currencyQUALIFY KATER__IS_IN_TOP(EVAL(shop.sum__amount), 2) OVER (PARTITION BY shop.currency)ORDER BY shop.currency, 3 DESC| Region | Currency | Revenue |
|---|---|---|
| EMEA | EUR | €84,371.35 |
| LATAM | USD | $116,577.89 |
| AMER | USD | $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.
SELECT shop.region, shop.currency, EVAL(shop.sum__amount, REMOVE(shop.currency))FROM workspace.shop AS shopGROUP BY shop.region, shop.currencyThe 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:
-- @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:
kater new test revenue_needs_currency --model semantic.shop \ --sql-file revenue_by_region.sql --refused --sharedIt 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:
---name: revenue_needs_currencymodel_type: testexpect: refused: not_additive_across---
SELECT shop.region, shop.sum__amountFROM semantic.shop AS shopGROUP BY shop.regionThe 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 by | any of 16 dimensions |
| Required | one Currency per result row: group by it or filter it to one value |
| Not allowed | none |
Rules
- The rule goes on a measure. Only a
MEASUREtakesNOT_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
SETit in theEVAL. 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
WHEREorREQUIRED_FILTERthat 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
| Code | Cause | Fix |
|---|---|---|
not_additive_across | A 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_across | The 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_across | The 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_declaration | NOT_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. |