On this page
- How it works
- The context decides the number
- Totals come from the rows, never from other totals
- Syntax
- Aggregations
- Examples
- Revenue, held to one currency
- Customers, a count of distinct values
- Average Discount, and the average of averages
- A filtered measure, two ways
- In the editor
- Where measures live
- Rules
- Common errors
- Next
Model Fields Measures
Measures
Declare a total once, and let every reader work it out again at its own grouping and filters.
A measure is a named aggregate, such as Revenue or Orders. Kater does not store its value. Each query that uses it works it out again from the source rows, at that query’s grouping and filters. A chart shows its query’s rows as they are. So the same measure gives the right number in every query, chart, app and agent answer that reads it.
How it works
The context decides the number
A measure’s context is the grouping and the filters of the place that uses it. Orders with no grouping counts every order of the sample shop:
SELECT shop.count_distinct__order_idFROM workspace.shop AS shop| Orders |
|---|
| 3,000 |
The same measure grouped by region gives one number per region:
SELECT shop.region, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.region| Region | Orders |
|---|---|
| AMER | 728 |
| APAC | 671 |
| EMEA | 777 |
| LATAM | 760 |
| null | 64 |
Nothing about Orders changed between the two queries. Only the context changed.
Totals come from the rows, never from other totals
Customers counts distinct customers. Grouped by region, it gives these rows:
SELECT shop.region, shop.count_distinct__customer_idFROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.region| Region | Customers |
|---|---|
| AMER | 232 |
| APAC | 229 |
| EMEA | 231 |
| LATAM | 228 |
| null | 56 |
The rows add up to 976, but the shop has 240 customers. Most customers ordered in more than one region, so each of them counts once in every region they ordered in. A query of Customers with no grouping gives 240, because Kater counts the distinct customers again from the order rows:
SELECT shop.count_distinct__customer_idFROM workspace.shop AS shop| Customers |
|---|
| 240 |
So a model with measures is like a function, not a table. Its grouped rows are a preview of one run. A reader that adds up those rows gets a wrong number, so Kater never does that. It works each measure out again from the source rows, in the reader’s context.
Try it: How measures work lets you change a query and see which orders each number reads.
Syntax
-- @description What one unit of the measure is.-- @format number(0)MEASURE('name', formula, NOT_ADDITIVE_ACROSS(dimension, ...)) AS "Label"| Part | What it does | Example |
|---|---|---|
MEASURE | Declares a reusable aggregate, in the model’s final SELECT. | MEASURE(...) |
'name' | The field name, and its identity. Other models read the measure by this name. | 'sum__amount' |
formula | What the measure computes. It must aggregate rows, or evaluate other measures. | KATER__SUM(orders.amount) |
NOT_ADDITIVE_ACROSS(...) | Optional, last argument. The dimensions the measure cannot be added up across. See Measures you cannot add up. | NOT_ADDITIVE_ACROSS(currency) |
AS "Label" | The label people see. Changing it changes nothing that reads the measure. | AS "Revenue" |
-- @ directives | The description, the format and more, above the declaration. See Formats, descriptions and AI context. | -- @format currency(currency, 2) |
- The formula aggregates rows or evaluates measures.
KATER__SUM(orders.amount)aggregates rows.EVAL(shop.sum__amount) / NULLIF(EVAL(shop.count_distinct__customer_id), 0)evaluates two measures. A formula that does neither is a dimension. - Most measures come from the Aggregations menu. It writes the declaration, the name and the format for you. Write a measure by hand only for a formula that no menu writes, such as a ratio. See Measures built from measures.
Aggregations
The Aggregations menu on a field writes one of these functions. The values are for the whole sample shop, with no grouping. Revenue is the exception: it needs one currency per row, so it has one value per currency.
| Function | What it does | Name it gives | Format it writes | On the sample shop |
|---|---|---|---|---|
KATER__SUM | Adds up the values. | sum__amount | The format of the field it reads, or number(2) when it has none and can have decimals | Revenue: $370,665.44 in USD and €84,371.35 in EUR |
KATER__AVG | The average of the values that are not null. | avg__discount | The format of the field it reads, or number(2) when it has none | Average Discount: 0.0581 |
KATER__MIN | The smallest value. | min__amount | The format of the field it reads, or number(2) when it has none and can have decimals | Amount (Min): 12.50 |
KATER__MAX | The largest value. | max__amount | The format of the field it reads, or number(2) when it has none and can have decimals | Amount (Max): 600.00 |
KATER__COUNT | Counts the values that are not null. | count__customer_id | number(0) | Customer ID (Count): 3,000 |
KATER__COUNT_DISTINCT | Counts the different values that are not null. | count_distinct__customer_id | number(0) | Customer ID (Count Distinct): 240 |
KATER__COUNT_TRUE | Counts the rows where a true or false field is true. | count_true__is_gift | number(0) | Is Gift (Count True): 256 |
KATER__COUNT_FALSE | Counts the rows where a true or false field is false. | count_false__is_gift | number(0) | Is Gift (Count False): 2,744 |
KATER__LIST | Joins each different value once into one text, in text order, separated by a comma and a space. Text order puts 10 before 9. A list longer than 512 characters is cut and ends in ”…”. | list__region | None | Region (List) on 1 January 2025: AMER, LATAM |
- Count and Count Distinct differ when a value repeats. Each of the 3,000 orders has a customer, so Customer ID (Count) is 3,000. Only 240 of those customers are different, so Customer ID (Count Distinct) is 240. Both skip nulls: Region (Count) is 2,936 and Region (Count Distinct) is 4, because 64 orders have no region.
- The menu offers what fits the field. A number marked
@continuous, such as Amount, offers Count, Count Distinct, Min, Max, Sum, Average and List. A number marked@categorical, such as Quantity, offers Count, Count Distinct and List. A text field offers Count, Count Distinct, Min, Max and List, and so does a date marked@categoricalor@continuous. A number or a date with neither mark offers only List. A true or false field offers Count True and Count False. - A label names the field and the operation. The operation labels its measure with the field’s label, such as “Customer ID (Count Distinct)”. Change the label to what people say, such as “Customers”.
- Plain SQL aggregates work too.
MEASURE('total_quantity', SUM(shop.quantity))gives 9,154, andCOUNT(DISTINCT shop.region)gives 4. TheKATER__form is what the menus write. A field written with it reopens in its operation’s form, and the compiler turns it into your warehouse’s own SQL.
Examples
Revenue, held to one currency
The orders Kater dataset declares Revenue as a sum of the order amount. Its rule says that it cannot be added up across currencies:
-- @description Order amount before discounts. MEASURE('sum__amount', KATER__SUM(orders.amount), NOT_ADDITIVE_ACROSS(currency)) AS "Revenue", /* @format currency(currency, 2) */A query of Revenue by region and currency:
SELECT shop.region, shop.currency, shop.sum__amount, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.region, shop.currencyORDER BY shop.region, shop.currency| Region | Currency | Revenue | Orders |
|---|---|---|---|
| AMER | USD | $105,459.33 | 728 |
| APAC | USD | $102,849.35 | 671 |
| EMEA | EUR | €84,371.35 | 532 |
| EMEA | USD | $36,129.01 | 245 |
| LATAM | USD | $116,577.89 | 760 |
| null | USD | $9,649.86 | 64 |
Each row holds one currency, so the rule holds, and the format shows each row in its own currency. EMEA has two rows because it sells in euros and in dollars. A query that drops the currency is refused. See Measures you cannot add up.
Customers, a count of distinct values
The customers Kater dataset declares Customers:
MEASURE('count_distinct__customer_id', KATER__COUNT_DISTINCT(customers.customer_id)) AS "Customers"Grouped by each customer’s loyalty tier:
SELECT shop."loyalty tier", shop.count_distinct__customer_id, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop."loyalty tier"ORDER BY shop."loyalty tier"| Loyalty Tier | Customers | Orders |
|---|---|---|
| bronze | 59 | 724 |
| gold | 58 | 708 |
| silver | 63 | 795 |
| null | 60 | 773 |
These rows add up to 240, because each customer has one tier. By region they add up to 976, because a customer can order in several regions. In both queries each number is right for its row, and Kater never adds the rows up.
Average Discount, and the average of averages
The orders Kater dataset declares Average Discount:
MEASURE('avg__discount', KATER__AVG(orders.discount)) AS "Average Discount"The semantic model of the sample shop does not pass Average Discount through. This example adds o.avg__discount to the semantic model’s SELECT, then groups it by region:
SELECT shop.region, shop.avg__discount, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.region| Region | Average Discount | Orders |
|---|---|---|
| AMER | 0.0593 | 728 |
| APAC | 0.0606 | 671 |
| EMEA | 0.0559 | 777 |
| LATAM | 0.0563 | 760 |
| null | 0.0664 | 64 |
The average of these five rows is 0.0597, but the shop’s Average Discount is 0.0581. The measure divides the sum of all 3,000 discounts by 3,000. An average of the five rows gives the 64 orders with no region the same weight as the 777 orders in EMEA. Kater works the average out again from the order rows, so a reader always gets 0.0581.
A filtered measure, two ways
Web orders can be a measure of its own in the model, or Orders with a different context in a reader. This conditional aggregate in the semantic model counts only the web orders:
MEASURE( 'web_orders', KATER__COUNT_DISTINCT(CASE WHEN o.channel = 'web' THEN o.order_id END) ) AS "Web Orders"A query that reads it:
SELECT shop.region, shop.web_ordersFROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.regionA query that sets the channel of Orders instead, with no new measure in the model:
SELECT shop.region, EVAL(shop.count_distinct__order_id, SET(shop.channel = 'web'))FROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.regionBoth queries give the same rows:
| Region | Web Orders |
|---|---|
| AMER | 160 |
| APAC | 170 |
| EMEA | 185 |
| LATAM | 174 |
| null | 17 |
| Use | When |
|---|---|
| A conditional aggregate in the model | Many readers need the same filtered number under one name, such as Web Orders. |
EVAL with SET in a reader | One analysis needs the number for one member. See Change a measure’s context. |
In the editor
This measure is the largest discount of any order in the group. It reads the Discount field of the orders Kater dataset.
- Open the orders Kater dataset in the Shared models editor.
- Right-click Discount in the field list.
- Under Make a new field from it, open Aggregations and select Max.
- The new-field dialog opens filled in: Measure, Start with Discount, and Turn it into Largest. It shows the Kater SQL and a preview of the values.
- Optional: under Name it, type Largest Discount as the Label.
- Select Add to the model.
MEASURE('max__discount', KATER__MAX(orders.discount)) AS "Largest Discount"In a workspace query, right-click a field in the field list. Aggregations is one of its categories. In the dialog, the Turn it into step names the aggregations Total, Average, Count, Count unique, Smallest, Largest and List. See Queries.
Where measures live
| Model | What its measures are for |
|---|---|
| Kater dataset | Totals over one table’s rows, such as Revenue and Orders in the orders dataset. |
| Semantic model | The datasets’ measures, passed through across joins, and measures that read more than one dataset. |
| Workspace root | The shared model’s measures, passed on to the workspace’s queries with the workspace’s filters. |
| Query | Measures for one analysis, such as Revenue per Customer in the Region Mix query. |
Tests and Sandbox scratch files can declare measures too. See How modeling works for the layers. A window is never a measure: a value worked out over a query’s result rows is a custom calculation.
Rules
- A measure aggregates. Its formula aggregates rows, or evaluates other measures with
EVAL. A constant or one row’s value is a dimension. - A window is never a measure. A reader works a measure out one group at a time. A window inside it would see only that group. Use
CUSTOM_CALCULATIONin a query, orDIMENSIONin a shared model. - A new measure needs a new name. It cannot take the name of any field in scope, in any letter case. Labels are not names, so two fields can share a label.
- A measure is never aggregated again.
SUM(shop.sum__quantity)would repeat each group’s total on every source row and add up the copies. Select the measure on its own for its total. - One value of a row is not a total. A Min or Max of a date, a text or an ID that you write by hand, such as
MAX(user_id), keeps one value of each group. The compiler warns withdimension_like_measureand offers to declare a dimension. Keep the measure only when “the latest order date per group” is the number you want. A Min, Max or List from the Aggregations menu gets no warning, because you selected it as a measure.
Common errors
| Code | Cause | Fix |
|---|---|---|
measure_requires_aggregation_or_evaluation | The formula aggregates nothing, such as MEASURE('amount_value', shop.amount). The compiler says “amount_value has one value on each row, so it cannot be a MEASURE”. | Aggregate the value, or apply the fix Declare as a dimension. |
measure_over_window | A window inside a MEASURE, such as RANK() OVER (...). In a query the compiler says “orders_rank is computed over this query’s rows, so it can’t be a reusable measure”. | Apply the fix Declare with CUSTOM_CALCULATION in a query, or Declare as a dimension in a shared model. |
field_name_in_scope | The name is already a field in scope, such as MEASURE('count_distinct__order_id', EVAL(shop.count_distinct__order_id)). The compiler says “count_distinct__order_id is already a measure in workspace.shop, which this model selects from”. | Apply the fix Use shop.count_distinct__order_id, which passes the measure through, or give the new measure a new name. |
invalid_measure_reaggregation | A measure inside an aggregate, such as SUM(shop.sum__quantity). The compiler says “shop.sum__quantity is a measure, so each group already has its value”. | Apply the fix Select shop.sum__quantity on its own. |
dimension_like_measure | Warning. A Min or Max of a date, a text or an ID, written by hand. | Declare it as a dimension, or rename it for the value it keeps, such as latest_order_date. |
Next
- How measures work: an interactive guide on the sample shop.
- Measures built from measures: ratios and formulas that stay right at every grouping.
- Change a measure’s context: the grand total, another member or another period beside the rows you show.
- Pass through, evaluate or declare: what readers of your model get when it reads a measure.
- Measures you cannot add up: rules such as one currency per row.
- Measures across joins and unions: how a measure counts its own rows once.