Skip to main content
On this page

Model Fields Dimensions

Dimensions

Declare the values people group and filter by, and know when a change keeps a field or makes a new one.

Audience
Data teams
Prerequisites
  • A model you can edit

A dimension is a value per row that people group or filter by, such as Region or Amount Band. Unlike a measure, Kater does not work a dimension out again for each grouping. It reads the value from each row. This page shows how to declare a dimension and when a change keeps a field. It also shows windows in a shared model.

How it works

A dimension holds one value for each row of its model, such as the region of each order. When a query groups by a dimension, the dimension splits the rows of each measure. Each region’s row of Orders counts only the orders of that region.

workspaces/shop/query_contracts/orders_by_region.sql
SELECT
shop.region,
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
shop.region
ORDER BY
2 DESC
RegionOrders
EMEA777
LATAM760
AMER728
APAC671
null64

Each order has one region, so the five rows add up to the 3,000 orders of the sample shop. The 64 orders with no region group under null.

You declare dimensions in every kind of model:

ModelIts dimensionsExample from the sample shop
Kater datasetOne for each column of its table, then the ones you add. Every output needs a declaration.DIMENSION('channel', orders.attributes ->> '$.channel') AS "Channel"
Custom SQL datasetEvery output needs a declaration.
Semantic modelThe dimensions it passes through from its sources, and new ones over them.DIMENSION('amount_band', KATER__BUCKET(o.amount, [25, 100])) AS "Amount Band"
Workspace rootEvery field of its source, with SELECT *, and the ones it declares.SELECT * FROM semantic.shop
QueryThe dimensions it groups and filters by, and its own.DIMENSION('segment', shop.region || ' ' || shop.channel) AS "Segment"

Syntax

-- @description What one value means.
DIMENSION('name', expression) AS "Label"
PartWhat it doesExample
DIMENSIONThe declaration.DIMENSION(...)
First argumentThe field name, and its identity. Other models read the field by this name. By convention, lower snake case.'order_size'
Second argumentThe formula. It reads values of one row.CASE WHEN orders.amount >= 100 THEN 'large' ELSE 'small' END
AS "Label"The display label. Optional. It is for people only: a change to it changes nothing that reads the field.AS "Order Size"
-- @ directives above itDescription, format, classification and AI context. See Formats, descriptions and AI context.-- @categorical
  • Declarations are outermost. A DIMENSION sits in the final SELECT, never inside a CTE, a subquery or a union branch.
  • A new field needs a new name. It cannot take the name of a field in scope, from any model the SELECT reads, in any letter case.
  • The formula decides the kind of field. A DIMENSION over a window in a query is a custom calculation, and the compiler warns you. See Custom calculations.

Pass through or declare

How you write a field from a source decides what you get. Some changes keep the source’s field, under its own name. Every other change makes a new value, which needs a new name.

You writeYou get
shop.regionThe same dimension, under its own name.
EVAL(shop.region)The same. EVAL of a dimension takes no modifiers.
One format or timeframe step: UPPER, LOWER, TRIM, INITCAP, ROUND, a date grain such as KATER__MONTH, the date format (TO_CHAR on Snowflake, STRFTIME on DuckDB), CAST, TRY_CAST or parenthesesThe same field, under its own name. An alias is only a label. The Format and Timeframe menus write these steps.
Any other change: arithmetic, COALESCE, NULLIF, ABS, LEFT, CONCAT or CASEA new value. Declare it with DIMENSION under a new name.
A new value with no declarationAn unnamed output. Its own model’s results and charts can use it, but no other model can read it. The hint unnamed_output offers to name it.

A format step keeps the field

The Region Codes query writes each region in upper case.

workspaces/shop/query_contracts/region_codes.sql
SELECT
UPPER(shop.region) AS region_code,
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
UPPER(shop.region)
region_codeOrders
APAC671
AMER728
LATAM760
null64
EMEA777

UPPER is one format step, so the column is still the region field. The alias region_code is only its label. A query that reads Region Codes selects the column by its field name:

workspaces/shop/query_contracts/orders_by_region_code.sql
SELECT
q.region,
q.count_distinct__order_id
FROM query.region_codes AS q

The rows are the same five, still labelled region_code.

Any other change makes a new value

The Region Labels query replaces a missing region with “Unknown”, and declares nothing.

workspaces/shop/query_contracts/region_labels.sql
SELECT
COALESCE(shop.region, 'Unknown') AS region_label,
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
COALESCE(shop.region, 'Unknown')

The query runs, and the 64 orders with no region show as Unknown. The compiler adds a hint: “region_label has no field name, so other models can’t read it.” A query that reads q.region_label from it is refused: “Column ‘region_label’ isn’t in ‘query.region_labels’ (as q).”

COALESCE gives a different value, so it is a new field and needs a name. The hint’s fix, Name this field as a dimension, declares it under its alias. A label for missing values shows the declared form.

Examples

Sizes from a CASE

Which orders are large? The orders dataset declares Order Size by hand, because no operation writes a CASE.

modeling/kater_objects/datasets/orders.sql
DIMENSION('order_size', CASE WHEN orders.amount >= 100 THEN 'large' ELSE 'small' END) AS "Order Size",
workspaces/shop/query_contracts/orders_by_size.sql
SELECT
shop.order_size,
shop.count_distinct__order_id,
shop.sum__quantity
FROM workspace.shop AS shop
GROUP BY
shop.order_size
ORDER BY
1
Order SizeOrdersUnits
large1,4255,123
small1,5754,031

An order of exactly 100 is large, because the CASE tests >= 100. The 31 orders with no amount are small: a CASE sends a missing value to its ELSE. A bucket keeps them null. See Groups, buckets and flags.

A value read from JSON

How many orders came through each channel? The orders table keeps the channel and the campaign in a JSON column, attributes. The orders dataset reads them out as dimensions:

modeling/kater_objects/datasets/orders.sql
DIMENSION('channel', orders.attributes ->> '$.channel') AS "Channel",
DIMENSION('campaign', (
orders.attributes -> '$.campaign'
) ->> '$.name') AS "Campaign",
workspaces/shop/query_contracts/channel_orders.sql
SELECT
shop.channel,
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
shop.channel
ORDER BY
2 DESC
ChannelOrders
email778
paid762
store754
web706

On DuckDB, ->> reads a JSON value as text, and -> reads it as JSON. Campaign reads the name inside a nested object. Write the same thing on another warehouse with that warehouse’s JSON functions.

A label for missing values

How do you show the orders with no region as “No region”? Declare the COALESCE under its own name.

workspaces/shop/query_contracts/region_labels.sql
SELECT
DIMENSION('region_label', COALESCE(shop.region, 'No region')) AS "Region Label",
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
1
ORDER BY
2 DESC
Region LabelOrders
EMEA777
LATAM760
AMER728
APAC671
No region64

The 64 orders that grouped under null now have a label. Region Label is a new field, so another query can read it as q.region_label.

Two fields in one

Which pairs of region and channel bring the most orders? Join the two values into one segment.

workspaces/shop/query_contracts/segments.sql
SELECT
DIMENSION('segment', shop.region || ' ' || shop.channel) AS "Segment",
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
1
ORDER BY
2 DESC
SegmentOrders
EMEA paid204
EMEA email202
LATAM paid201
LATAM store199
AMER paid197

The first 5 of 17 rows. Four regions and four channels make 16 segments. The 17th row is null: when the region is missing, || gives null, so those 64 orders have no segment. Wrap the region in COALESCE to give them one.

A dimension from a joined model

How many orders and customers does each country have? Country belongs to the customers dataset. semantic.shop joins each order to its customer, and passes Country through with this line:

modeling/kater_objects/semantic_models/shop.sql
c.country,

The join that brings in c sits below the semantic model’s SELECT:

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')

A query of the workspace groups by it like any other dimension:

workspaces/shop/query_contracts/orders_by_country.sql
SELECT
shop.country,
shop.count_distinct__order_id,
shop.count_distinct__customer_id
FROM workspace.shop AS shop
GROUP BY
shop.country
ORDER BY
2 DESC
CountryOrdersCustomers
US49142
JP45737
CA40830
GB39631
BR34328
FR31826
null31426
DE27320

Each customer has one country, so Customers adds up to the shop’s 240. By region, Customers adds up to 976. Region belongs to the order, and most customers ordered in more than one region. See Relationships and joins.

A measure’s value as a dimension

How many days had one order, two orders, and so on? Evaluate Orders for each day, then group the days by that number.

workspaces/shop/query_contracts/orders_per_day.sql
WITH daily AS (
SELECT
shop.order_date,
EVAL(shop.count_distinct__order_id) AS orders_that_day
FROM workspace.shop AS shop
GROUP BY
shop.order_date
)
SELECT
daily.orders_that_day,
MEASURE('days', KATER__COUNT(daily.order_date)) AS "Days"
FROM daily
GROUP BY
daily.orders_that_day
ORDER BY
1
OrdersDays
129
274
390
4124
5108
669
766
835

The first 8 of 12 rows. The busiest day had 14 orders. EVAL fixes Orders at the CTE’s grouping, one value for each day. That value is now a dimension, so the outer SELECT groups by it like any other. The 12 rows count 633 days with at least one order.

Classification

-- @categorical and -- @continuous say what kind of values a dimension holds. The editor calls this Values.

DirectiveThe values areExamples from the sample shop
-- @categoricalSeparate values: identifiers, labels, flags, buckets and other categoriesOrder ID, Region, Quantity, Amount Band
-- @continuousA quantity, or a point or a span in timeAmount, Order Date, Order Month

A number can be either. A numeric ID is categorical, and a count can be continuous. The classification changes what Kater offers for the field:

  • Filter values. A categorical field’s filter offers a list of its values. A continuous number or date offers a range, from its smallest to its largest value.
  • The Derive menu. A continuous number offers Bucket, Is Greater Than, Is Less Than and Is Between. A categorical number, such as a code, offers Is Equal To, Is One Of and Map in their place. See Groups, buckets and flags.
  • Explore. When Explore draws a query’s result, a continuous number is a value on the chart. A categorical number, such as an ID, is a category.

Where a classification comes from:

  • Sync writes it for each column of a database dataset. Sync reads a sample of up to 100,000 rows. Dates, times, floats and numbers with decimal places are continuous. A whole number is continuous when more than half of its values are distinct. Text, true or false, JSON and other whole numbers are categorical. So the shop’s Quantity is categorical.
  • A reference keeps it. A pass-through, an alias or a cast keeps the classification of the field it reads.
  • Kater works out many formulas. A comparison or a text function is categorical. A date part is a categorical number, and a date grain is continuous. When Kater cannot tell, the field has none until you set it. Channel, read from JSON, has none.
  • You set it. Select Values in the field panel, the field menu or the Fields sheet: Automatic, Categorical or Continuous. In a query, the field menu has Classification under Edit. Each writes the directive through the field.classify operation.

Windows in a shared model

In a Kater dataset or a semantic model, a window over the model’s own rows is a dimension. Such a window runs over dimensions only. A window over a measure needs a query’s grouping to mean anything. Write it in a query, where it is a custom calculation. The Windows menu in a shared model offers windows on dimensions only, and writes them as DIMENSION.

Where the window can sit

Kater works out a measure again from its model’s rows for each reader. It cannot work out a window again from those rows. So a model with no GROUP BY cannot hold a window beside a measure that readers group or filter by it. The compiler refuses it with unsupported_measure_context.

For example, this line in semantic.shop numbers each customer’s orders, oldest first:

modeling/kater_objects/semantic_models/shop.sql
DIMENSION(
'order_sequence',
ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at, o.order_id)
) AS "Order Sequence",

The semantic model compiles. But a query that groups Orders by Order Sequence is refused: “measure ‘count_distinct__order_id’ can’t be grouped or filtered by a value its source computes with a window over its own rows, because the measure’s rows can’t recompute that window. Select the value in a query first, then group or filter by it in a query that reads that one.”

Where the window sitsWhat happens
A Kater dataset that declares measures, such as the orders datasetThe compiler refuses the dataset itself.
A semantic model with no GROUP BYThe model compiles. A reader that groups or filters a measure by the window is refused.
A Kater dataset with no measures, joined to the model with the measuresIt works. Each reader can group and filter by the window.
A queryThe window is a custom calculation over the query’s result rows.

Each customer’s first, second and later orders

How many first orders, second orders and later orders did the shop get? Put the window in a Kater dataset of its own, with no measures. A standard Kater dataset declares every column of its table first, so the window comes after them:

modeling/kater_objects/datasets/order_sequence.sql
---
name: order_sequence
model_type: kater_dataset
label: Order Sequence
---
SELECT
DIMENSION('order_id', orders.order_id) AS "Order ID",
DIMENSION('customer_id', orders.customer_id) AS "Customer ID",
DIMENSION('region', orders.region) AS "Region",
DIMENSION('currency', orders.currency) AS "Currency",
DIMENSION('order_date', orders.order_date) AS "Order Date",
DIMENSION('ordered_at', orders.ordered_at) AS "Ordered At",
DIMENSION('amount', orders.amount) AS "Amount",
DIMENSION('quantity', orders.quantity) AS "Quantity",
DIMENSION('discount', orders.discount) AS "Discount",
DIMENSION('is_gift', orders.is_gift) AS "Is Gift",
DIMENSION('notes', orders.notes) AS "Notes",
DIMENSION('attributes', orders.attributes) AS "Attributes",
DIMENSION('tags', orders.tags) AS "Tags",
-- @description The order's position among its customer's orders, starting at 1.
DIMENSION(
'order_sequence',
ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.ordered_at, orders.order_id)
) AS "Order Sequence"
FROM shop.sales.orders AS orders

Then semantic.shop joins it one to one on the order, and passes Order Sequence through:

modeling/kater_objects/semantic_models/shop.sql
seq.order_sequence,
modeling/kater_objects/semantic_models/shop.sql
LEFT JOIN RELATIONSHIP('sequence', kater_dataset.order_sequence AS seq ON o.order_id = seq.order_id, 'one_to_one')
workspaces/shop/query_contracts/orders_by_sequence.sql
SELECT
shop.order_sequence,
shop.count_distinct__order_id
FROM workspace.shop AS shop
GROUP BY
shop.order_sequence
ORDER BY
1
Order SequenceOrders
1240
2240
3240
4240
5240
6238
7232
8221

The first 8 of 26 rows. Every one of the 240 customers has at least five orders, so the first five rows each count 240. One customer has 26 orders. The window runs in its own dataset, so Orders reads the finished numbers through the join. order_id breaks ties between two orders placed at the same time.

You can also select the window’s value in a query first, without a measure. A second query that reads that one can then group by it, with a measure of its own.

Steps in a session

The GA4 Event Steps model numbers each event in its session, and names the next event.

modeling/kater_objects/semantic_models/marts/GA4_EVENT_STEPS.sql
-- @description Position of this event in its session, starting at 1.
DIMENSION(
'step_number',
ROW_NUMBER() OVER (PARTITION BY events.session_key ORDER BY events.event_timestamp, events.event_key)
) AS "Step Number",
-- @description The session's next event, or empty when this is the session's last event.
DIMENSION(
'next_event_name',
LEAD(events.event_name) OVER (PARTITION BY events.session_key ORDER BY events.event_timestamp, events.event_key)
) AS "Next Event",

Rules

  • A dimension is a value per row. A reader groups and filters by it. It does not total it again.
  • One format or timeframe step keeps the field. Any other change is a new value, and needs a new name.
  • A SELECT holds a field once. Two outputs that keep the same field, such as two grains of Order Date, are refused. Declare one of them under its own name. See Dates and times.
  • Every output of a dataset needs a declaration. In a Kater dataset or a custom SQL dataset, even UPPER(orders.region) needs a DIMENSION.
  • A standard Kater dataset keeps every column. It declares each column of its table first, unchanged. Add new dimensions after them.
  • A label never names a field. SQL reads a field by its name, never by its label. A field has no @label directive.
  • A workspace root cannot run a window. Its optional filters apply first, so the value would change for each reader. Put the window in a query, or in the shared model.

Common errors

CodeCauseFix
unnamed_outputHint. A new value with no declaration, such as COALESCE(shop.region, 'Unknown') AS region_label. No other model can read it.Apply Name this field as a dimension, or declare it with DIMENSION.
field_name_in_scopeThe new name is a field in scope, such as DIMENSION('region', COALESCE(shop.region, 'No region')).Pass the source field through, or give the new field another name.
alias_matches_field_in_scopeWarning. A calculation with a label that is a field’s name, such as COALESCE(shop.region, 'No region') AS "Region". It shows on screen as that field.Give it another label, or declare it with its own name.
named_final_select_requiredAn output of a Kater dataset or custom SQL dataset with no declaration.Wrap it as DIMENSION('name', value) or MEASURE('name', formula).
dataset_missing_base_dimensionsA standard Kater dataset leaves out a column of its table, or changes one.Declare each column of the table unchanged, and add new fields after them.
duplicate_field_nameTwo outputs of one SELECT keep the same field, such as KATER__YEAR(shop.order_date) and KATER__MONTH(shop.order_date).Declare one of them under its own name.
custom_calculation_declared_as_dimensionWarning. A DIMENSION over a window in a query. It is published as a custom calculation.Apply Declare with CUSTOM_CALCULATION.
dimension_declared_as_custom_calculationWarning. A CUSTOM_CALCULATION over a value of one row, such as UPPER(shop.region). It is published as a dimension.Apply Declare as a dimension.
custom_calculation_not_allowedCUSTOM_CALCULATION in a Kater dataset or a semantic model.Apply Declare as a dimension.
measure_over_windowMEASURE over a window in a Kater dataset or a semantic model.Apply Declare as a dimension.
unsupported_measure_contextA window in a model with no GROUP BY, beside a measure that is grouped or filtered by it.Put the window in a Kater dataset with no measures and join it. Or select the value in a query first.
window_not_allowedA window in a workspace root.Put it in a query, or in the shared model as a dimension.

Next