Skip to main content
On this page

Model Fields Measures

Measures built from measures

Combine measures into ratios and formulas that stay right at every grouping.

Audience
Data teams
Prerequisites
  • A model you can edit
  • The basics of measures, on the Measures overview

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.

StepFor 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. DivideRevenue 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"
PartWhat it doesExample
EVAL(measure)Works out an input measure in the reader’s context.EVAL(shop.sum__amount)
The formulaAny 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 nameA 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:

workspaces/shop/query_contracts/usd_revenue_per_customer.sql
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 shop
WHERE
shop.currency = 'USD'
GROUP BY
shop.region,
shop.currency
ORDER BY
shop.region
RegionCurrencyRevenueCustomersRevenue per Customer
AMERUSD$105,459.33232$454.57
APACUSD$102,849.35229$449.12
EMEAUSD$36,129.01145$249.17
LATAMUSD$116,577.89228$511.31
nullUSD$9,649.8656$172.32

The same query grouped by currency only gives the value for all USD orders:

workspaces/shop/query_contracts/usd_revenue_per_customer_total.sql
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 shop
WHERE
shop.currency = 'USD'
GROUP BY
shop.currency
CurrencyRevenueCustomersRevenue per Customer
USD$370,665.44240$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:

workspaces/shop/query_contracts/revenue_per_customer_by_currency.sql
SELECT
q.currency,
q.revenue_per_customer
FROM query.usd_revenue_per_customer AS q
GROUP BY
q.currency
CurrencyRevenue 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:

workspaces/shop/query_contracts/units_per_order.sql
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 shop
GROUP BY
shop.channel
ORDER BY
shop.channel
ChannelUnitsOrdersUnits per Order
email2,3837783.06
paid2,2927623.01
store2,3307543.09
web2,1497063.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:

workspaces/shop/query_contracts/units_per_large_order.sql
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 shop
GROUP BY
shop.region
ORDER BY
shop.region
RegionUnits per Large OrderLarge OrdersUnits in Large Orders
AMER3.633271,188
APAC3.563331,184
EMEA3.673721,367
LATAM3.513601,263
null3.6733121

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:

workspaces/shop/query_contracts/large_per_small.sql
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 shop
GROUP BY
shop.order_size
ORDER BY
shop.order_size
Order SizeLarge OrdersSmall OrdersLarge Orders per Small Order
large1,4250null
small01,5750.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:

modeling/kater_objects/semantic_models/order_share.sql
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 shares
GROUP BY
region
ORDER BY
region
RegionShare of Orders
AMER24.3%
APAC22.4%
EMEA25.9%
LATAM25.3%
null2.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:

workspaces/shop/query_contracts/average_orders_per_month.sql
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:

workspaces/shop/query_contracts/average_orders_per_month_by_region.sql
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_month
GROUP BY
per_month.region
ORDER BY
per_month.region
RegionAverage Orders per Month
AMER34.67
APAC31.95
EMEA37.00
LATAM36.19
null3.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 EVAL and 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. Two EVALs 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 MEASURE directly has no GROUP BY. A stage that groups works when the MEASURE aggregates its rows again, such as with KATER__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

CodeCauseFix
not_additive_acrossA 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_fieldTwo 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_stageA 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_evaluationThe formula has no aggregate and no EVAL of a measure.Wrap each input in EVAL, or declare a DIMENSION.
unsupported_measure_contextSiblings read different sources, and a join repeats one’s rows for the other.See Measures across joins and unions.