Model Fields Measures
Pass through, evaluate or declare
Decide what readers of your model get when it reads a measure: the same measure, a fixed value, or a new measure.
When a model reads a measure from its source, how you write it decides what readers of the model get. A pass-through gives them the same measure. An EVAL gives them a value fixed at your model’s grouping. A MEASURE declaration gives them a new measure that you own. A tick in the field list writes a pass-through.
The three choices
These lines come from the Region Mix query:
SELECT shop.region_group, shop.sum__amount, 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| You write | Readers of your model get | Use it when |
|---|---|---|
shop.sum__amount, or * | A pass-through: the same measure, with this model’s filters and joins added. A reader totals it again from the source rows, at the reader’s own grouping. | Readers should group the measure their own way. |
EVAL(shop.sum__amount) | The measure’s value at this SELECT’s grouping. It is exported as a dimension, fixed at this grain. | You need the number here, and nobody should total it again. |
MEASURE('revenue_per_customer', EVAL(...) / NULLIF(EVAL(...), 0)) | A new measure with its own name. Readers work it out again at their grouping, from the totals it reads. | The meaning changes, such as a ratio of two measures. |
shop.region_group or EVAL(shop.region_group) | The same dimension, with the same name. | Always. A dimension has one form. |
EVAL(a) / EVAL(b) AS ratio, with no declaration | An unnamed output. It works in this model’s own results and charts, but no other model can read it. | Only for a value this model shows itself. The hint unnamed_output offers to name it. |
An AS alias changes the label only. shop.sum__amount AS "Sales" is still the pass-through of Revenue, with the name sum__amount.
What readers get
These examples save two queries of the sample shop, then read each of them from a third query. A query reads another query of its workspace as FROM query.<name>.
Pass the measures through
The query orders_by_region passes Orders and Customers through, grouped by region:
SELECT shop.region_group, shop.region, shop.count_distinct__order_id, shop.count_distinct__customer_idFROM workspace.shop AS shopGROUP BY shop.region_group, shop.regionORDER BY shop.region_group, shop.region| Region Group | Region | Orders | Customers |
|---|---|---|---|
| Americas | AMER | 728 | 232 |
| Europe | EMEA | 777 | 231 |
| Rest of World | APAC | 671 | 229 |
| Rest of World | LATAM | 760 | 228 |
| Rest of World | null | 64 | 56 |
A reader with no grouping:
SELECT q.count_distinct__order_id, q.count_distinct__customer_idFROM query.orders_by_region AS q| Orders | Customers |
|---|---|
| 3,000 | 240 |
A reader grouped by region group:
SELECT q.region_group, q.count_distinct__order_id, q.count_distinct__customer_idFROM query.orders_by_region AS qGROUP BY q.region_groupORDER BY q.region_group| Region Group | Orders | Customers |
|---|---|---|
| Americas | 728 | 232 |
| Europe | 777 | 231 |
| Rest of World | 1,495 | 240 |
Each reader counts again from the order rows. Rest of World has 240 customers, not 229 + 228 + 56, because Kater counts each customer once.
Evaluate the measures
The query orders_by_region_fixed is the same query with EVAL around each measure:
SELECT shop.region_group, shop.region, EVAL(shop.count_distinct__order_id), EVAL(shop.count_distinct__customer_id)FROM workspace.shop AS shopGROUP BY shop.region_group, shop.regionORDER BY shop.region_group, shop.regionIts own rows are the same as those of orders_by_region. But a reader now gets two dimensions, fixed at the region. A reader with no grouping gets the five fixed values:
SELECT q.region, q.count_distinct__order_id, q.count_distinct__customer_idFROM query.orders_by_region_fixed AS q| Region | Orders | Customers |
|---|---|---|
| AMER | 728 | 232 |
| EMEA | 777 | 231 |
| APAC | 671 | 229 |
| LATAM | 760 | 228 |
| null | 64 | 56 |
To get one total, the reader has to add the fixed values up:
SELECT MEASURE('orders_added_up', KATER__SUM(q.count_distinct__order_id)) AS "Orders Added Up", MEASURE('customers_added_up', KATER__SUM(q.count_distinct__customer_id)) AS "Customers Added Up"FROM query.orders_by_region_fixed AS q| Orders Added Up | Customers Added Up |
|---|---|
| 3,000 | 976 |
Orders adds up to the right number by chance, because each order has one region. Customers adds up to 976, because a customer who ordered in four regions counts four times. The pass-through gave 240. Evaluate a measure only when its value at your grouping is the number readers need.
Declare a new measure
A MEASURE declaration over EVALs is a measure of its own. Readers work it out again at their grouping. See Measures built from measures for Revenue per Customer, which a reader grouped by currency works out again as $1,544.44.
When Kater writes EVAL for you
Kater writes EVAL around a measure where a pass-through cannot stand, and when you lock a measure in:
- In
HAVING,QUALIFYorORDER BY. A condition or a sort reads the measure’s value at the model’s grouping. - Inside an expression.
EVAL(shop.sum__quantity) / 1000works the measure out at the model’s grouping, then divides. The result is a new value: an unnamed dimension until you declare it. - In a SELECT that has
HAVINGorQUALIFY. These clauses decide which rows count from other rows. So the SELECT cannot pass a measure through, and it evaluates each measure instead. - When you lock a measure in. In a query, Edit › Lock in on a measure turns its pass-through into
EVAL.
Filter by this number
On a measure’s column in a query, Filters opens with Is In The Top and Is In The Bottom. The number conditions follow, such as Is Greater Than. This query shows Orders and Units by region:
SELECT shop.region, shop.count_distinct__order_id, shop.sum__quantityFROM workspace.shop AS shopGROUP BY shop.regionFilters › Is Greater Than on Orders, with 700, writes the condition in HAVING. In the same edit, it evaluates every measure the SELECT passes through, and keeps their names and labels:
SELECT shop.region, EVAL(shop.count_distinct__order_id), EVAL(shop.sum__quantity)FROM workspace.shop AS shopGROUP BY shop.regionHAVING KATER__IS_GREATER_THAN(EVAL(shop.count_distinct__order_id), 700)| Region | Orders | Units |
|---|---|---|
| AMER | 728 | 2,179 |
| LATAM | 760 | 2,260 |
| EMEA | 777 | 2,421 |
APAC, with 671 orders, and the 64 orders with no region drop out. A filter on a measure applies to its own query only.
Top N
Filters › Is In The Top writes a rank condition in QUALIFY. The Top Regions query keeps the 5 region and currency rows with the most revenue:
SELECT shop.region, shop.currency, EVAL(shop.sum__amount), EVAL(shop.count_distinct__order_id)FROM workspace.shop AS shopGROUP BY shop.region, shop.currencyQUALIFY KATER__IS_IN_TOP(EVAL(shop.sum__amount), 5)ORDER BY 3 DESC| Region | Currency | Revenue | Orders |
|---|---|---|---|
| LATAM | USD | $116,577.89 | 760 |
| AMER | USD | $105,459.33 | 728 |
| APAC | USD | $102,849.35 | 671 |
| EMEA | EUR | €84,371.35 | 532 |
| EMEA | USD | $36,129.01 | 245 |
This ranks euros against dollars. To rank within each currency, partition the condition. OVER (PARTITION BY shop.currency) keeps the top region of each currency:
SELECT shop.region, shop.currency, EVAL(shop.sum__amount), EVAL(shop.count_distinct__order_id)FROM workspace.shop AS shopGROUP BY shop.region, shop.currencyQUALIFY KATER__IS_IN_TOP(EVAL(shop.sum__amount), 1) OVER (PARTITION BY shop.currency)ORDER BY shop.currency| Region | Currency | Revenue | Orders |
|---|---|---|---|
| EMEA | EUR | €84,371.35 | 532 |
| LATAM | USD | $116,577.89 | 760 |
A partition keeps a measure that cannot be added up across currencies from being ranked across them.
Top N and Bottom N keep ties, so they can keep more rows than the count. From June to September 2026, July and September both had 137 orders. The bottom 2 months are three months:
SELECT shop.order_month, EVAL(shop.count_distinct__order_id)FROM workspace.shop AS shopWHERE shop.order_month >= DATE '2026-06-01'GROUP BY shop.order_monthQUALIFY KATER__IS_IN_BOTTOM(EVAL(shop.count_distinct__order_id), 2)ORDER BY EVAL(shop.count_distinct__order_id) ASC NULLS LAST| Order Month | Orders |
|---|---|
| 2026-06-01 | 136 |
| 2026-07-01 | 137 |
| 2026-09-01 | 137 |
A query with no ORDER BY gets one from Top N or Bottom N, so its rows come out in rank order. A query that already sorts keeps its own sort.
A measure the model declares itself
HAVING, QUALIFY and ORDER BY can also read a measure that the same SELECT declares. They apply to the model’s results, so they read the measure at the model’s grouping:
SELECT shop.region, MEASURE('large_orders', KATER__COUNT_DISTINCT(CASE WHEN shop.order_size = 'large' THEN shop.order_id END)) AS "Large Orders"FROM workspace.shop AS shopGROUP BY shop.regionHAVING EVAL(large_orders) > 340| Region | Large Orders |
|---|---|
| LATAM | 360 |
| EMEA | 372 |
Anything else in the same condition must be one of the model’s outputs.
What a reader never sees
A model’s GROUP BY, ORDER BY, LIMIT, OFFSET and DISTINCT shape only its own run. The query top_two_regions keeps the two regions with the most orders:
SELECT shop.region, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.regionORDER BY 2 DESCLIMIT 2| Region | Orders |
|---|---|
| EMEA | 777 |
| LATAM | 760 |
A reader of it with no grouping:
SELECT q.count_distinct__order_idFROM query.top_two_regions AS q| Orders |
|---|
| 3,000 |
The reader gets 3,000, not 777 + 760. A passed-through measure is totalled again from the source rows, so the LIMIT does not reach it. To hand readers only the top rows, use Top N. It evaluates the measure, so a reader of the Top Regions query gets its 5 rows and their fixed values.
Renaming a measure
A measure that is passed through keeps its name. * RENAME cannot give it a new one:
SELECT * RENAME (count_distinct__order_id AS orders)FROM workspace.shop AS shopThe compiler refuses it: “RENAME can’t give the measure count_distinct__order_id a new name, because a measure passed through keeps its name.” Declare the new name with MEASURE instead. This query is saved as orders_renamed:
SELECT shop.region, MEASURE('orders', EVAL(shop.count_distinct__order_id)) AS "Orders"FROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.regionA reader of it with no grouping:
SELECT q.ordersFROM query.orders_renamed AS q| Orders |
|---|
| 3,000 |
orders is a new measure, so readers still work it out again from the order rows. To change only what people see, change the label: shop.count_distinct__order_id AS "Order Count" keeps the name and the pass-through.
Rules
- A pass-through is the default. A tick in the field list, a bare name and
*all pass the measure through. Readers can then group it any way. EVALfixes the value. Readers of anEVALoutput get a dimension at your grouping. Adding it up again can give a wrong number.- A new measure needs a new name.
MEASURE('count_distinct__order_id', EVAL(shop.count_distinct__order_id))takes a name that is already in scope, so the compiler refuses it. HAVINGandQUALIFYshow the value at your grouping. A measure beside either shows its value at this grouping, asEVALdoes, and readers can’t add it up again. The compiler warns you to writeEVAL, so the SQL says so.- A reader never sees your row shape. Your
GROUP BY,ORDER BY,LIMIT,OFFSETandDISTINCTshape only your own run. - Rules carry through. A pass-through, and a measure built on another, keep its rules, such as one currency per row. See Measures you cannot add up.
Common errors
| Code | Cause | Fix |
|---|---|---|
field_name_in_scope | A declaration takes a name that is 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 writes the pass-through. Or give the new measure a new name. |
measure_evaluated_beside_clause | Warning. A measure in a SELECT that has HAVING or QUALIFY. The compiler says “shop.count_distinct__order_id in the SELECT shows its value at this grouping, because the query has HAVING”. | Apply the fix Write EVAL(shop.count_distinct__order_id). A filter from the field menu writes it for you. |
invalid_measure_rename | * RENAME on a measure. | Declare the new name with MEASURE('new_name', EVAL(...)), or change only the label. |
invalid_measure_reaggregation | A measure inside an aggregate, such as SUM(shop.sum__quantity). | Select the measure on its own for its total. |
unnamed_output | Hint. A calculation with no declaration, which no other model can read. | Apply the fix that names it as a measure or a dimension. |