Model Fields Measures
Measures built from measures
Combine measures into ratios and formulas that stay right at every grouping.
A measure can be built from other measures, such as Revenue per Customer or Units per Order. Its formula works out each input measure in the reader’s context, then combines the results. So a ratio is always the ratio of two totals, at whatever grouping the reader asks for.
How it works
Inside a MEASURE, EVAL(m) works out the measure m in the context of the reader: its grouping and its filters. The formula then combines the values. Kater adds up first and divides last.
| Step | For all USD orders |
|---|---|
1. Work out EVAL(shop.sum__amount) | Revenue: $370,665.44 |
2. Work out EVAL(shop.count_distinct__customer_id) | Customers: 240 |
| 3. Divide | Revenue per Customer: $1,544.44 |
A reader that groups by region gets the same three steps once per region. No reader ever gets an average of other rows’ ratios.
Syntax
MEASURE('name', EVAL(measure_a) / NULLIF(EVAL(measure_b), 0)) AS "Label"| Part | What it does | Example |
|---|---|---|
EVAL(measure) | Works out an input measure in the reader’s context. | EVAL(shop.sum__amount) |
| The formula | Any SQL over the EVAL values: arithmetic, CASE, NULLIF, COALESCE. | EVAL(a) / NULLIF(EVAL(b), 0) |
NULLIF(value, 0) | Turns a zero denominator into null, so the group’s value is empty. | NULLIF(EVAL(shop.count_distinct__order_id), 0) |
| A sibling’s name | A measure declared in the same SELECT, read by its name. | EVAL(large_orders) |
Examples
Revenue per Customer
The Region Mix query declares Revenue per Customer as Revenue divided by Customers. This query declares the same measure for USD orders, grouped by region:
SELECT shop.region, shop.currency, shop.sum__amount, shop.count_distinct__customer_id, MEASURE( 'revenue_per_customer', EVAL(shop.sum__amount) / NULLIF(EVAL(shop.count_distinct__customer_id), 0) ) AS "Revenue per Customer" /* @format currency(currency, 2) */FROM workspace.shop AS shopWHERE shop.currency = 'USD'GROUP BY shop.region, shop.currencyORDER BY shop.region| Region | Currency | Revenue | Customers | Revenue per Customer |
|---|---|---|---|---|
| AMER | USD | $105,459.33 | 232 | $454.57 |
| APAC | USD | $102,849.35 | 229 | $449.12 |
| EMEA | USD | $36,129.01 | 145 | $249.17 |
| LATAM | USD | $116,577.89 | 228 | $511.31 |
| null | USD | $9,649.86 | 56 | $172.32 |
The same query grouped by currency only gives the value for all USD orders:
SELECT shop.currency, shop.sum__amount, shop.count_distinct__customer_id, MEASURE( 'revenue_per_customer', EVAL(shop.sum__amount) / NULLIF(EVAL(shop.count_distinct__customer_id), 0) ) AS "Revenue per Customer" /* @format currency(currency, 2) */FROM workspace.shop AS shopWHERE shop.currency = 'USD'GROUP BY shop.currency| Currency | Revenue | Customers | Revenue per Customer |
|---|---|---|---|
| USD | $370,665.44 | 240 | $1,544.44 |
The average of the five region rows is $367.30, not $1,544.44. Most customers ordered in more than one region, so the region rows count them 890 times in all. The measure for all USD orders divides all the revenue by the 240 different customers.
Save the first query as usd_revenue_per_customer. A query that reads it and groups by currency only gets $1,544.44 too. It calculates both totals again, then divides:
SELECT q.currency, q.revenue_per_customerFROM query.usd_revenue_per_customer AS qGROUP BY q.currency| Currency | Revenue per Customer |
|---|---|
| USD | $1,544.44 |
Units per Order
Units and Orders can be added up across every dimension, so a ratio of the two needs no currency rule:
SELECT shop.channel, shop.sum__quantity, shop.count_distinct__order_id, MEASURE( 'units_per_order', EVAL(shop.sum__quantity) / NULLIF(EVAL(shop.count_distinct__order_id), 0) ) AS "Units per Order" /* @format number(2) */FROM workspace.shop AS shopGROUP BY shop.channelORDER BY shop.channel| Channel | Units | Orders | Units per Order |
|---|---|---|---|
| 2,383 | 778 | 3.06 | |
| paid | 2,292 | 762 | 3.01 |
| store | 2,330 | 754 | 3.09 |
| web | 2,149 | 706 | 3.04 |
With no grouping, the same measure gives 9,154 units divided by 3,000 orders: 3.05.
A formula over two measures declared beside it
A measure can read measures that the same SELECT declares. Names bind before any measure is worked out, so the order of the declarations does not matter. Here Units per Large Order sits first and reads the two measures below it:
SELECT shop.region, MEASURE( 'units_per_large_order', EVAL(large_units) / NULLIF(EVAL(large_orders), 0) ) AS "Units per Large Order", /* @format number(2) */ MEASURE( 'large_orders', KATER__COUNT_DISTINCT(CASE WHEN shop.order_size = 'large' THEN shop.order_id END) ) AS "Large Orders", /* @format number(0) */ MEASURE( 'large_units', KATER__SUM(CASE WHEN shop.order_size = 'large' THEN shop.quantity END) ) AS "Units in Large Orders" /* @format number(0) */FROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.region| Region | Units per Large Order | Large Orders | Units in Large Orders |
|---|---|---|---|
| AMER | 3.63 | 327 | 1,188 |
| APAC | 3.56 | 333 | 1,184 |
| EMEA | 3.67 | 372 | 1,367 |
| LATAM | 3.51 | 360 | 1,263 |
| null | 3.67 | 33 | 121 |
EVAL(large_units) names a sibling, so it has no alias in front. A plain EVAL of a sibling works it out in the same context as the measure that reads it: here, each region.
Leave a group with no denominator empty
Large Orders per Small Order divides by Small Orders. Grouped by order size, the large row has no small orders:
SELECT shop.order_size, MEASURE( 'large_orders', KATER__COUNT_DISTINCT(CASE WHEN shop.order_size = 'large' THEN shop.order_id END) ) AS "Large Orders", /* @format number(0) */ MEASURE( 'small_orders', KATER__COUNT_DISTINCT(CASE WHEN shop.order_size = 'small' THEN shop.order_id END) ) AS "Small Orders", /* @format number(0) */ MEASURE( 'large_per_small', EVAL(large_orders) / NULLIF(EVAL(small_orders), 0) ) AS "Large Orders per Small Order" /* @format number(2) */FROM workspace.shop AS shopGROUP BY shop.order_sizeORDER BY shop.order_size| Order Size | Large Orders | Small Orders | Large Orders per Small Order |
|---|---|---|---|
| large | 1,425 | 0 | null |
| small | 0 | 1,575 | 0.00 |
NULLIF turns the zero into null, so the large row is empty. Without NULLIF, DuckDB gives inf for that row, and Snowflake stops the query with a division-by-zero error. With no grouping, the measure gives 1,425 divided by 1,575: 0.90.
A formula in stages
A formula can run through CTEs. Kater keeps every stage and each EVAL’s own context, so a reader still gets a measure. This semantic model works out each region’s share of all orders. The first EVAL follows the reader’s grouping, and the CLEAR() one is always the total of all orders:
WITH shares AS ( SELECT o.region, EVAL(o.count_distinct__order_id) / NULLIF(EVAL(o.count_distinct__order_id, CLEAR()), 0) AS share FROM kater_dataset.orders AS o)SELECT region, MEASURE('order_share', share) AS "Share of Orders" /* @format percent(1) */FROM sharesGROUP BY regionORDER BY region| Region | Share of Orders |
|---|---|
| AMER | 24.3% |
| APAC | 22.4% |
| EMEA | 25.9% |
| LATAM | 25.3% |
| null | 2.1% |
The stage has no GROUP BY. A stage that feeds a MEASURE this way cannot group, because a grouped stage fixes its values at its own grouping. With GROUP BY o.region inside shares, the compiler refuses the model: “order_share cannot be a MEASURE. The CTE shares groups its rows with GROUP BY, so its totals are fixed there.” See Change a measure’s context for CLEAR().
Total again at another grain
To average a measure over months, work it out per month in a stage that groups. Then aggregate the stage’s rows again in the measure:
WITH per_month AS ( SELECT shop.order_month, EVAL(shop.count_distinct__order_id) AS month_orders FROM workspace.shop AS shop GROUP BY shop.order_month)SELECT MEASURE('avg__month_orders', KATER__AVG(per_month.month_orders)) AS "Average Orders per Month" /* @format number(2) */FROM per_month| Average Orders per Month |
|---|
| 142.86 |
The shop has 3,000 orders in 21 months, and 3,000 divided by 21 is 142.86. Here the stage can group, because the measure aggregates the stage’s rows again with KATER__AVG.
The stage’s GROUP BY sets the grain the measure averages over. This version groups the stage by region and month, and the query by region:
WITH per_month AS ( SELECT shop.region, shop.order_month, EVAL(shop.count_distinct__order_id) AS month_orders FROM workspace.shop AS shop GROUP BY shop.region, shop.order_month)SELECT per_month.region, MEASURE('avg__month_orders', KATER__AVG(per_month.month_orders)) AS "Average Orders per Month" /* @format number(2) */FROM per_monthGROUP BY per_month.regionORDER BY per_month.region| Region | Average Orders per Month |
|---|---|
| AMER | 34.67 |
| APAC | 31.95 |
| EMEA | 37.00 |
| LATAM | 36.19 |
| null | 3.05 |
A reader of this query with no grouping gets 28.57: the average of all 105 region months, not of the 21 months. Group the stage only by what the measure averages over.
Rules
- A measure built on measures keeps their rules. Revenue per Customer reads Revenue, so each row needs one currency. Grouped by region alone, the compiler refuses it: “Revenue cannot be added up across Currency. Group by Currency or filter it to one value.” See Measures you cannot add up.
- Add up first, divide last. Put each total in its own
EVALand combine them in the formula. Never average a column of ratios. - Divide with
NULLIF. A zero denominator then gives an empty value on every warehouse. - Several
EVALs of one measure can share a formula.EVAL(m) / NULLIF(EVAL(m, CLEAR()), 0)is one output. TwoEVALs of the same measure as two outputs of one SELECT both take the measure’s name, so the compiler refuses them. Declare the second with its own name. - A stage that feeds a
MEASUREdirectly has noGROUP BY. A stage that groups works when theMEASUREaggregates its rows again, such as withKATER__AVG. - Siblings over a repeating join are refused. Two siblings can read different sources, and the model’s joins can repeat one’s rows for the other. A measure that combines them then fails with
unsupported_measure_context. See Measures across joins and unions.
Common errors
| Code | Cause | Fix |
|---|---|---|
not_additive_across | A formula reads Revenue in a query that holds no single currency per row. | Group by shop.currency, or filter it to one value. |
duplicate_output_field | Two outputs of one SELECT are EVALs of the same measure. The compiler says “Two outputs are named ‘count_distinct__order_id’. An AS alias only labels an output”. | Declare one of them with its own name, such as MEASURE('all_orders', EVAL(shop.count_distinct__order_id, CLEAR())). |
unsupported_reusable_definition_stage | A stage with GROUP BY feeds a MEASURE directly. | Take the GROUP BY out of the stage, aggregate the stage’s rows in the MEASURE, or declare a DIMENSION for a fixed value. |
measure_requires_aggregation_or_evaluation | The formula has no aggregate and no EVAL of a measure. | Wrap each input in EVAL, or declare a DIMENSION. |
unsupported_measure_context | Siblings read different sources, and a join repeats one’s rows for the other. | See Measures across joins and unions. |