Skip to main content
On this page

Model Fields Measures

Measures across joins and unions

Know how a measure counts its rows when a model joins other tables or stacks row sets.

Audience
Data teams
Prerequisites
  • A semantic model with a join, or a query of one

A measure totals its own rows: the rows of the sources its formula reads. The model’s other joins decide which of those rows count. They also let a reader group or filter by the joined columns. They never multiply a measure’s rows. So a join cannot count an order twice, and a total stays right, whatever the model joins.

How it works

Each row counts once in each group, however many rows it matches. Take one order with three events: created, paid and shipped. Group Units by event type, and the order’s units count once under each of the three types. In the total, they count once.

The join, seen from the measure’s rowsEach row countsThe groups add up to the total
Many to one, such as an order to its customerOnce, in the one group its match is inYes
From the one side, such as a customer’s measure grouped by an order’s regionOnce in each group it matchesNo
One to many, such as an order to its eventsOnce in each group it matchesNo

When the groups do not add up, each group is still right. It answers its own question, such as “how many units were in orders that were shipped”. Read the total from the measure itself, never from a sum of the groups.

Joins that cannot repeat rows

Some joins cannot repeat a measure’s rows. The compiler keeps the measure’s formula as written across them:

JoinWhy it cannot repeat rows
A RELATIONSHIP declared many_to_one or one_to_one, for a measure of the side before the joinEach row matches at most one joined row.
A RELATIONSHIP declared one_to_many or one_to_one, for a measure of the joined sideEach joined row matches at most one row of the side before it.
A grouped or DISTINCT source, matched on every one of its keysThe source has one row for each key.
A source that keeps the first row of each ROW_NUMBER() OVER (PARTITION BY …), matched on its partition columnsThe source has one row for each partition.
A source with one rowEvery row matches the same row.

Any other join is treated as able to repeat rows. Then the compiler works each measure out on its own rows first, and counts each row once in each group. The same is true when another model passes the measure through or evaluates it. A filter or grouping on a side that model joins applies to the rows it reaches, and never multiplies them.

The compiler trusts the cardinality you declare in a shared model. many_to_one does not remove duplicate keys. In a query, a measure the query declares is worked out on its own rows first unless the compiler proves from the sources’ SQL that the join cannot repeat them, whatever cardinality the query declares. A query is read at the grain of its groups, so a cardinality written there is easy to get wrong. Check each join on the warehouse with kater model check-join, or in the join panel of the Shared models editor. Its Measures line says how measures behave across that join. See Relationships and joins.

Examples

A many to one join adds up

semantic.shop joins each order to its customer, many to one:

modeling/kater_objects/semantic_models/shop.sql
FROM kater_dataset.orders AS o
-- @label Customer
LEFT JOIN RELATIONSHIP('customer', kater_dataset.customers AS c ON o.customer_id = c.customer_id, 'many_to_one')

Orders and Units by the customer’s loyalty tier:

workspaces/shop/query_contracts/orders_by_loyalty_tier.sql
SELECT
shop."loyalty tier",
shop.count_distinct__order_id,
shop.sum__quantity
FROM workspace.shop AS shop
GROUP BY
shop."loyalty tier"
ORDER BY
shop."loyalty tier"
Loyalty TierOrdersUnits
bronze7242,245
gold7082,099
silver7952,444
null7732,366

The tiers add up to 3,000 orders and 9,154 units, the shop’s totals. Each order has one customer, so it lands in one tier. The null row holds the orders of customers with no tier.

A measure of the one side counts once in each group

Customers by the region of their orders. For this example, the customers dataset also declares a sum of lifetime value, and semantic.shop passes it through:

-- In kater_dataset.customers
MEASURE('sum__lifetime_value', KATER__SUM(customers.lifetime_value)) AS "Lifetime Value Total"
-- In semantic.shop
c.sum__lifetime_value,
workspaces/shop/query_contracts/lifetime_value_by_region.sql
SELECT
shop.region,
shop.count_distinct__customer_id,
shop.sum__lifetime_value
FROM workspace.shop AS shop
GROUP BY
shop.region
ORDER BY
shop.region
RegionCustomersLifetime Value Total
AMER232555,873.67
APAC229546,040.93
EMEA231551,840.58
LATAM228541,884.92
null56127,825.21

The same measures with no grouping:

workspaces/shop/query_contracts/lifetime_value_total.sql
SELECT
shop.count_distinct__customer_id,
shop.sum__lifetime_value
FROM workspace.shop AS shop
CustomersLifetime Value Total
240573,460.22

The regions add up to 976 customers, but the shop has 240. Most customers ordered in more than one region, and each counts once in every region they ordered in. Each row is right: AMER had 232 customers. The total is right too: 573,460.22 is the sum over the customers table. A plain SQL SUM over the same join counts each customer once for each order, and gives 7,209,398.55.

A one to many join counts each order once in each group

This semantic model joins each order to its events, one to many:

modeling/kater_objects/semantic_models/activity.sql
SELECT
o.order_id,
o.region,
e.eventtype,
o.sum__quantity,
o.count_distinct__order_id,
e.count__event_id
FROM kater_dataset.orders AS o
-- @label Events
LEFT JOIN RELATIONSHIP('events', kater_dataset.order_events AS e ON o.order_id = e.order_id, 'one_to_many')

kater model check-join confirms the cardinality on the warehouse:

events: 3,000 of 3,000 rows match. Each matches at most 4 rows.
The joined model has 7,436 rows and 3,000 key values.
The rows fit one to many.

A query of its workspace, activity, groups Units, Orders and Events by event type:

workspaces/activity/query_contracts/units_by_event_type.sql
SELECT
activity.eventtype,
activity.sum__quantity,
activity.count_distinct__order_id,
activity.count__event_id
FROM workspace.activity AS activity
GROUP BY
activity.eventtype
ORDER BY
activity.eventtype
Event TypeUnitsOrdersEvents
created9,1543,0003,000
delivered2,336755755
paid6,8152,2312,231
shipped4,4241,4501,450

The same measures with no grouping:

workspaces/activity/query_contracts/units_total.sql
SELECT
activity.sum__quantity,
activity.count_distinct__order_id,
activity.count__event_id
FROM workspace.activity AS activity
UnitsOrdersEvents
9,1543,0007,436

Every order has a created event, so the created row holds every order. An order that was also paid and shipped counts under those types too. So the Units rows add up to 22,729, and the total stays 9,154. A plain SQL SUM of the quantity over the same join gives 22,729 for the total. It adds each order once for each event.

A measure that reads both sides of a repeating join

Orders per Customer, declared in semantic.shop:

modeling/kater_objects/semantic_models/shop.sql
MEASURE('orders_per_customer', EVAL(o.count_distinct__order_id) / NULLIF(EVAL(c.count_distinct__customer_id), 0)) AS "Orders per Customer",

The compiler refuses the model with unsupported_measure_context: “orders_per_customer evaluates measures that read different sources, and this model’s joins repeat one’s rows for the other. Declare it in a model where they read one source, or evaluate each in the query that needs it.”

The join is many to one toward customers, so it repeats each customer’s row once for each order. A formula that reads both sides in this model would read those copies. Declare the measure in a query of the workspace, where each measure is worked out on its own rows first:

workspaces/shop/query_contracts/orders_per_customer.sql
SELECT
shop."loyalty tier",
shop.count_distinct__order_id,
shop.count_distinct__customer_id,
-- @format number(1)
MEASURE(
'orders_per_customer',
EVAL(shop.count_distinct__order_id) / NULLIF(EVAL(shop.count_distinct__customer_id), 0)
) AS "Orders per Customer"
FROM workspace.shop AS shop
GROUP BY
shop."loyalty tier"
ORDER BY
shop."loyalty tier"
Loyalty TierOrdersCustomersOrders per Customer
bronze7245912.3
gold7085812.2
silver7956312.6
null7736012.9

Each tier’s ratio divides its own two totals. The same holds for Events per Order on the activity model: the model refuses it, and a query of its workspace gives AMER 2.41.

Several row sets in one model

A model can hold several row sets as UNION ALL branches, such as web orders and store orders. Read the union through a SELECT * CTE, and declare each dimension. A merged dimension is a new field, so it needs a new name. That is true even when every branch reads a field of that name. DIMENSION('region', base.region) is refused, because region is already a dimension of kater_dataset.orders.

This semantic model, channel_units, stacks the shop’s web orders and store orders. The store branch writes NULL for the campaign:

modeling/kater_objects/semantic_models/channel_units.sql
WITH unioned AS (
SELECT o.region, o.order_month, o.campaign, o.quantity AS web_units, CAST(NULL AS INTEGER) AS store_units
FROM kater_dataset.orders AS o
WHERE o.channel = 'web'
UNION ALL
SELECT o.region, o.order_month, CAST(NULL AS VARCHAR) AS campaign, NULL AS web_units, o.quantity AS store_units
FROM kater_dataset.orders AS o
WHERE o.channel = 'store'
), base AS (
SELECT * FROM unioned
)
SELECT
DIMENSION('union__region', base.region) AS "Region",
DIMENSION('union__order_month', base.order_month) AS "Order Month",
DIMENSION('union__campaign', base.campaign) AS "Campaign",
MEASURE('web_units', SUM(base.web_units)) AS "Web Units",
MEASURE('store_units', SUM(base.store_units)) AS "Store Units"
FROM base

Each measure totals the branches its aggregate reads. Web Units totals the web rows, and Store Units totals the store rows. The row sets meet on the dimensions they share. A branch that writes NULL for a dimension, directly or through a cast, does not carry it. The compiler works all of this out from the SQL. Nothing is declared.

Two row sets meet on the dimensions they share

Web Units and Store Units by region, from the workspace channels that reads channel_units.

workspaces/channels/query_contracts/units_by_region.sql
SELECT
channels.union__region,
channels.web_units,
channels.store_units
FROM workspace.channels AS channels
GROUP BY
channels.union__region
ORDER BY
channels.union__region
RegionWeb UnitsStore Units
AMER495534
APAC520545
EMEA577605
LATAM504605
null5341

Both branches carry the region, so each measure totals its own rows for each region. The columns add up to 2,149 web units and 2,330 store units.

A measure cannot be grouped by a dimension its rows do not have

Store Units by campaign.

workspaces/channels/query_contracts/store_units_by_campaign.sql
SELECT
channels.union__campaign,
EVAL(channels.store_units)
FROM workspace.channels AS channels
GROUP BY
channels.union__campaign

The compiler refuses it with dimension_outside_measure_rows: “Store Units can’t be grouped by Campaign. Some of the rows it totals have no Campaign, so they would all count toward an empty Campaign. Remove Campaign from the GROUP BY, or write EVAL(store_units, REMOVE(union__campaign)) to total Store Units without it.”

A filter on the campaign, such as WHERE channels.union__campaign = 'spring', is refused with the same code: “Store Units can’t be filtered by Campaign. Some of the rows it totals have no Campaign, so the filter would drop them all.”

Total a measure without that dimension

Web Units by campaign, beside all Store Units.

workspaces/channels/query_contracts/web_units_by_campaign.sql
SELECT
channels.union__campaign,
EVAL(channels.web_units),
EVAL(channels.store_units, REMOVE(channels.union__campaign))
FROM workspace.channels AS channels
GROUP BY
channels.union__campaign
ORDER BY
channels.union__campaign
CampaignWeb UnitsStore Units
holiday5102,330
none5302,330
spring5082,330
summer6012,330
nullnull2,330

REMOVE takes the campaign out of the grouping for Store Units only, so every row shows all 2,330 store units. Web Units keeps the grouping. The null row is the store rows, which have no campaign, so it has no web units. See Change a measure’s context for REMOVE.

Where you see it

A measure’s definition card lists, under Inputs, the dimensions a reader can group or filter it by. A dimension outside the measure’s rows is left out there and listed under Not allowed. Open the Definitions view, or run kater model explain store_units --relation semantic.channel_units:

Inputs
Group or filter byany of 2 dimensions: Region, Order Month
Requirednone
Not allowedCampaign

The workspace keeps the limit when it passes Store Units on, so a query of channels meets it too.

Rules

  • A measure totals its own rows. The rows are those of the sources its formula reads, and nothing else.
  • Joins never multiply a measure’s rows. Each row counts once in each group, however many rows it matches.
  • Groups from a repeating join do not add up. Each group is right. Read the total from the measure, not from a sum of the groups.
  • A declared cardinality changes no data. The compiler trusts it. Check each join on the warehouse.
  • A formula reads one set of rows. A measure that evaluates two sources across a repeating join is refused. Declare it in a query of the model.
  • A plain aggregate has no such guard. A plain SQL SUM over a one to many join counts each row once for each match. Use a measure.
  • Each union branch carries only the dimensions it writes. A measure cannot be grouped or filtered by a dimension that its branch writes as NULL.

Common errors

CodeCauseFix
unsupported_measure_contextA measure evaluates measures of two sources, and a join of the model repeats one’s rows for the other. The code has other causes too, see Pass through, evaluate or declare.Declare the measure in a query of the model, or in a model where both measures read one source.
dimension_outside_measure_rowsA query groups or filters a measure by a dimension that the union branch it totals writes as NULL.Remove the dimension, or write EVAL(measure, REMOVE(dimension)) to total the measure without it.
field_name_in_scopeA merged union dimension takes the name of a field that a branch reads.Give it a new name, such as union__region.
relationship_pruned_row_effectWarning. A query reads nothing from a join that can repeat rows, so the compiler leaves the join out.Nothing, for a measure: it gives the same total either way. Units is 9,154 with the events join and without it. For raw rows, see Relationships and joins.