Metric Grain and How Queries Are Assembled¶
Grain is the idea that decides which numbers a semantic view can return. It is worth meeting while you are designing a model, rather than the first time a query comes back with a value you did not expect. This page explains what grain is, why a metric’s grain belongs to its table rather than to the query you wrote, how the extension assembles a query whose metrics sit at different grains, and which shapes it still refuses.
If you are here because a query raised fan trap detected, the diagnostic
route is How to Understand and Avoid Fan Traps. This page is the modelling route.
What Grain Means¶
The grain of a table is the level of detail one row represents. A
customers table has one row per customer, so its grain is “one customer”. An
orders table has one row per order. A line_items table has one row per
item within an order. Reading down that list, the grain gets finer: each order
belongs to exactly one customer, and each line item belongs to exactly one
order.
Grain is what makes a sum meaningful. SUM(c.balance) over the customers
table adds each customer’s balance exactly once, because there is exactly one
row per customer. The same expression evaluated over a join of customers to
orders adds each balance once per order that customer placed, which is not a
balance total – it is a balance total weighted by order volume. Nothing in the
SQL looks wrong. Only the grain of the rows being summed changed.
Tip
If you have modelled a star schema before, grain is the discipline you already apply when you decide what one row of a fact table means. Semantic views make that discipline explicit and enforce it at query time.
Grain Belongs to the Metric’s Table, Not to the Query¶
In a semantic view, every metric is declared against a table alias:
METRICS (
o.order_count AS COUNT(*),
c.total_balance AS SUM(c.balance)
)
That prefix is not decoration. o.order_count counts rows of orders;
c.total_balance sums rows of customers. The metric carries its grain
with it wherever it is queried, because the grain is a fact about where the
numbers live – not about which dimensions a particular caller asked for.
This is the same rule Snowflake applies, and it is what lets one semantic view
serve queries that a single CREATE VIEW could not. A regular view has to
commit to one join shape and one GROUP BY; a semantic view keeps each metric
attached to its own grain and works out the join shape per query. See
Semantic Views vs. Regular SQL Views for that contrast in full.
The practical consequence for modelling: declare each metric on the table whose rows it aggregates. A customer-level metric belongs on the customer table even if most of your queries start from orders. Declaring it on the orders table to keep the definition tidy does not move the numbers to the order grain; it only describes them incorrectly.
A Single-Grain Query Is One SELECT¶
When every metric in a query sits at the same grain, the extension generates one
statement anchored at the view’s base table – the first table declared in
the TABLES clause – with a LEFT JOIN out to each table a queried
dimension needs, and one GROUP BY.
Take the three-table shop model from Multi-Table Semantic Views: both
revenue and order_count are declared on o (orders), so grouping
either of them by a customer or product dimension is a single-grain query.
Joining outward from orders to customers is safe because each order has exactly
one customer – traversing from the “many” side to the “one” side never
duplicates a row.
This is the ordinary case, and everything that follows leaves it alone.
A Multi-Grain Query Is One Aggregate per Grain¶
Changed in version 0.12.0: Queries whose metrics sit at different grains are computed per grain and joined. Before, the generated SQL was always anchored at the base table, so these queries were rejected with a fan-trap error rather than answered.
Consider a view whose base table is orders but which also carries a
customer-level metric:
CREATE TABLE customers (id INTEGER, region VARCHAR, balance DECIMAL(10,2));
INSERT INTO customers VALUES
(1, 'East', 500.00),
(2, 'East', 300.00),
(3, 'West', 900.00);
CREATE TABLE orders (id INTEGER, customer_id INTEGER, amount DECIMAL(10,2));
INSERT INTO orders VALUES
(1, 1, 25.00),
(2, 1, 50.00),
(3, 2, 75.00);
CREATE SEMANTIC VIEW accounts AS
TABLES (
o AS orders PRIMARY KEY (id),
c AS customers PRIMARY KEY (id)
)
RELATIONSHIPS (
order_customer AS o(customer_id) REFERENCES c
)
DIMENSIONS (
c.region AS c.region
)
METRICS (
o.order_count AS COUNT(*),
c.total_balance AS SUM(c.balance)
);
Note the data: customer 1 placed two orders, customer 2 placed one, and customer 3 – the only customer in the West region – placed none.
Asking for both metrics by region is a two-grain query:
SELECT * FROM semantic_view('accounts',
dimensions := ['region'],
metrics := ['order_count', 'total_balance']
) ORDER BY region;
┌────────┬─────────────┬───────────────┐
│ region │ order_count │ total_balance │
├────────┼─────────────┼───────────────┤
│ East │ 3 │ 800.00 │
│ West │ NULL │ 900.00 │
└────────┴─────────────┴───────────────┘
Both numbers are what a careful analyst would compute by hand: East has three orders and two customers holding 800.00 between them, and West holds 900.00 while having placed nothing yet.
Anchoring the query at orders – the only option before v0.12.0 – would have
produced neither. Customer 1’s balance would have been added once per order, so
East’s total would read 1300.00, and West would have vanished entirely, because
no order row exists to carry it. That is why the base-anchored path rejected this
query with a fan-trap error instead of answering it.
The Shapes This Covers¶
Three query shapes are answered per grain:
- A metric on a parent table the base table references.
c.total_balanceabove – queried alone, or alongside dimensions at or above its own grain.- Metrics at two different grains, queried together.
An order-grain metric with a line-item-grain metric (a fan trap shape), or metrics on two different children of the same parent (a chasm trap shape).
- A single derived metric that fuses two grains.
avg_items AS item_count / order_count, where the numerator lives online_itemsand the denominator onorders. Each component is aggregated at its own grain and the arithmetic is evaluated over the two pre-aggregates, so the denominator is the true order count rather than a fanned one. See How to Compose Metrics with Derived Metrics for how derived metrics are declared.
How the Grains Are Recombined¶
Each grain becomes its own aggregate over its own table, and the results are joined on the dimensions the query asked for:
grain 1: aggregate the orders metrics GROUP BY the queried dimensions
grain 2: aggregate the customers metrics GROUP BY the queried dimensions
└── combined on the queried dimensions
with a NULL-safe FULL OUTER JOIN
Two properties of that recombination matter when you read results:
The join is a NULL-safe
FULL OUTER JOIN. A dimension group present at one grain but not another survives, carryingNULLfor the metrics it has no rows for. That is theNULLin the West row above: West has a balance but no orders. Nothing is silently dropped, andNULLhere means “no rows at this grain”, not “zero”.A query with no dimensions has nothing to join on, so the grains are combined with a
CROSS JOIN: each grain contributes its single grand-total row and the result is one row of columns.
This describes the shape of the generated SQL, not its literal text. To see exactly what was generated for a given query, use explain_semantic_view().
Note
Single-grain queries do not take this path at all – they generate exactly the SQL they always did. Per-grain assembly is entered only where the query would otherwise have been rejected, so adding a parent-table metric to a view cannot change the answer to a query that does not use it.
One requirement relaxes on this path. A COUNT(*) metric normally needs its
table to declare a PRIMARY KEY, because on the base-anchored path that table
is reached through a LEFT JOIN whose NULL-extended rows COUNT(*) would
count. A table that anchors its own grain produces no such rows, so the key is
not required there. Querying the same metric on the base-anchored path still
needs it.
What Is Still Refused, and Why¶
Per-grain assembly widens what a semantic view can answer; it does not remove the guard rails. Five shapes still raise an error, each for a reason worth knowing while you model.
- A dimension below a metric’s grain.
Grouping
c.total_balanceby an order-grain or line-item-grain dimension is rejected, and no amount of per-grain machinery makes it answerable: a customer with orders in three statuses genuinely fans across those statuses, so there is no single correct balance to report per group. Snowflake refuses the same shape by the same rule – the logical table for the dimension must be related to the logical table for the metric and must have “an equal or lower level of granularity”. Group by a dimension at or above the metric’s grain instead, or split the query in two.- Window metrics whose inner aggregates sit at different grains.
A window metric wraps an inner aggregate, and the window function runs over the already-grouped rows, so the window itself is not grain-sensitive – the inner aggregate is. One window metric whose inner aggregate lives on a non-base table is computed at that table’s grain. Two of them at different grains are not, because those grains would have to be joined before the window could run. See How to Use Window Function Metrics.
- A role-played table reached without ``USING``.
When one table is reachable through two named relationships –
flightsreferencingairportsonce as departure and once as arrival – a dimension on it means nothing until something says which role is intended. A co-queried metric’sUSINGclause supplies that, and per-grain assembly honours it: each grain joins the named relationship under its own scoped alias. WithoutUSINGthe query keeps the error rather than picking a relationship by declaration order, because picking silently is how a departure number ends up labelled as an arrival one. Declaring role-playing somewhere in a view does not cost the rest of that view its multi-grain queries – the test is what the query reaches, not what the definition contains. See How to Model Role-Playing Dimensions.- Anything other than a dimension sitting on a role-played table.
Only a queried dimension’s expression is rewritten to a scoped alias, so a pre-aggregation filter member on a role-played table, a metric aggregated at one, or a table reachable only by passing through one all remain errors. An error is the right outcome here; the alternative is a plausible-looking wrong number.
- A role-played dimension queried together with an active semi-additive metric.
A snapshot group cannot carry a role, so the two grains would bind different instances of the same dimension – one under the alias
USINGnamed, the other under whichever relationship was declared first – and the outer join would silently compare them. Query that pair at a single grain instead.
Semi-additive metrics – NON ADDITIVE BY snapshots such as account balances –
are otherwise computed at their own grain and can appear alongside metrics at other
grains. The snapshot ranking runs over the metric’s own table rather than over a
join that has already duplicated its rows, so a latest balance is no longer added
once per order. See How to Use Semi-Additive Metrics.
What This Means for a Model¶
Three habits keep a model on the right side of these rules:
Declare each metric on the table whose rows it aggregates. The alias prefix is the grain declaration; treat it as load-bearing.
Declare ``PRIMARY KEY`` and ``UNIQUE`` accurately in ``TABLES``. Cardinality is inferred from them, and cardinality is what tells the extension which direction of a relationship is safe to traverse. A missing key makes a one-to-one relationship look one-to-many and narrows what the view can answer.
Check before you query. SHOW SEMANTIC DIMENSIONS … FOR METRIC applies the same grain rules at inspection time and lists only the dimensions that can legitimately be combined with a given metric.
Tip
Grain also decides what a filter means. A predicate applied before
aggregation changes which rows each grain aggregates over, while an outer
WHERE filters only the assembled result. How to Filter Semantic View Queries covers the
difference.
Further Reading¶
How to Understand and Avoid Fan Traps – the diagnostic view of the same subject: what the
fan trap detectederror is telling you, and how to restructure around it.Snowflake Comparison – how this grain model lines up with Snowflake’s, including the granularity rule quoted above.
Multi-Table Semantic Views – the star-schema model these examples build on.
semantic_view() – the query function’s parameters and the emission paths a predicate is injected into.