On this page
- How it works
- Syntax
- Pass through or declare
- A format step keeps the field
- Any other change makes a new value
- Examples
- Sizes from a CASE
- A value read from JSON
- A label for missing values
- Two fields in one
- A dimension from a joined model
- A measure’s value as a dimension
- Classification
- Windows in a shared model
- Where the window can sit
- Each customer’s first, second and later orders
- Steps in a session
- Rules
- Common errors
- Next
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.
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.
SELECT shop.region, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.regionORDER BY 2 DESC| Region | Orders |
|---|---|
| EMEA | 777 |
| LATAM | 760 |
| AMER | 728 |
| APAC | 671 |
| null | 64 |
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:
| Model | Its dimensions | Example from the sample shop |
|---|---|---|
| Kater dataset | One 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 dataset | Every output needs a declaration. | |
| Semantic model | The 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 root | Every field of its source, with SELECT *, and the ones it declares. | SELECT * FROM semantic.shop |
| Query | The 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"| Part | What it does | Example |
|---|---|---|
DIMENSION | The declaration. | DIMENSION(...) |
| First argument | The field name, and its identity. Other models read the field by this name. By convention, lower snake case. | 'order_size' |
| Second argument | The 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 it | Description, format, classification and AI context. See Formats, descriptions and AI context. | -- @categorical |
- Declarations are outermost. A
DIMENSIONsits 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
DIMENSIONover 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 write | You get |
|---|---|
shop.region | The 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 parentheses | The 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 CASE | A new value. Declare it with DIMENSION under a new name. |
| A new value with no declaration | An 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.
SELECT UPPER(shop.region) AS region_code, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY UPPER(shop.region)| region_code | Orders |
|---|---|
| APAC | 671 |
| AMER | 728 |
| LATAM | 760 |
| null | 64 |
| EMEA | 777 |
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:
SELECT q.region, q.count_distinct__order_idFROM query.region_codes AS qThe 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.
SELECT COALESCE(shop.region, 'Unknown') AS region_label, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP 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.
DIMENSION('order_size', CASE WHEN orders.amount >= 100 THEN 'large' ELSE 'small' END) AS "Order Size",SELECT shop.order_size, shop.count_distinct__order_id, shop.sum__quantityFROM workspace.shop AS shopGROUP BY shop.order_sizeORDER BY 1| Order Size | Orders | Units |
|---|---|---|
| large | 1,425 | 5,123 |
| small | 1,575 | 4,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:
DIMENSION('channel', orders.attributes ->> '$.channel') AS "Channel", DIMENSION('campaign', ( orders.attributes -> '$.campaign' ) ->> '$.name') AS "Campaign",SELECT shop.channel, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.channelORDER BY 2 DESC| Channel | Orders |
|---|---|
| 778 | |
| paid | 762 |
| store | 754 |
| web | 706 |
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.
SELECT DIMENSION('region_label', COALESCE(shop.region, 'No region')) AS "Region Label", shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY 1ORDER BY 2 DESC| Region Label | Orders |
|---|---|
| EMEA | 777 |
| LATAM | 760 |
| AMER | 728 |
| APAC | 671 |
| No region | 64 |
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.
SELECT DIMENSION('segment', shop.region || ' ' || shop.channel) AS "Segment", shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY 1ORDER BY 2 DESC| Segment | Orders |
|---|---|
| EMEA paid | 204 |
| EMEA email | 202 |
| LATAM paid | 201 |
| LATAM store | 199 |
| AMER paid | 197 |
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:
c.country,The join that brings in c sits below the semantic model’s SELECT:
FROM kater_dataset.orders AS o-- @label CustomerLEFT 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:
SELECT shop.country, shop.count_distinct__order_id, shop.count_distinct__customer_idFROM workspace.shop AS shopGROUP BY shop.countryORDER BY 2 DESC| Country | Orders | Customers |
|---|---|---|
| US | 491 | 42 |
| JP | 457 | 37 |
| CA | 408 | 30 |
| GB | 396 | 31 |
| BR | 343 | 28 |
| FR | 318 | 26 |
| null | 314 | 26 |
| DE | 273 | 20 |
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.
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 dailyGROUP BY daily.orders_that_dayORDER BY 1| Orders | Days |
|---|---|
| 1 | 29 |
| 2 | 74 |
| 3 | 90 |
| 4 | 124 |
| 5 | 108 |
| 6 | 69 |
| 7 | 66 |
| 8 | 35 |
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.
| Directive | The values are | Examples from the sample shop |
|---|---|---|
-- @categorical | Separate values: identifiers, labels, flags, buckets and other categories | Order ID, Region, Quantity, Amount Band |
-- @continuous | A quantity, or a point or a span in time | Amount, 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.classifyoperation.
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:
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 sits | What happens |
|---|---|
| A Kater dataset that declares measures, such as the orders dataset | The compiler refuses the dataset itself. |
A semantic model with no GROUP BY | The 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 measures | It works. Each reader can group and filter by the window. |
| A query | The 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:
---name: order_sequencemodel_type: kater_datasetlabel: 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 ordersThen semantic.shop joins it one to one on the order, and passes Order Sequence through:
seq.order_sequence,LEFT JOIN RELATIONSHIP('sequence', kater_dataset.order_sequence AS seq ON o.order_id = seq.order_id, 'one_to_one')SELECT shop.order_sequence, shop.count_distinct__order_idFROM workspace.shop AS shopGROUP BY shop.order_sequenceORDER BY 1| Order Sequence | Orders |
|---|---|
| 1 | 240 |
| 2 | 240 |
| 3 | 240 |
| 4 | 240 |
| 5 | 240 |
| 6 | 238 |
| 7 | 232 |
| 8 | 221 |
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.
-- @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 aDIMENSION. - 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
@labeldirective. - 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
| Code | Cause | Fix |
|---|---|---|
unnamed_output | Hint. 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_scope | The 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_scope | Warning. 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_required | An 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_dimensions | A 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_name | Two 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_dimension | Warning. A DIMENSION over a window in a query. It is published as a custom calculation. | Apply Declare with CUSTOM_CALCULATION. |
dimension_declared_as_custom_calculation | Warning. 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_allowed | CUSTOM_CALCULATION in a Kater dataset or a semantic model. | Apply Declare as a dimension. |
measure_over_window | MEASURE over a window in a Kater dataset or a semantic model. | Apply Declare as a dimension. |
unsupported_measure_context | A 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_allowed | A window in a workspace root. | Put it in a query, or in the shared model as a dimension. |
Next
- Dates and times: group dates by month or week, and pull out parts such as the day of the week.
- Groups, buckets and flags: turn values into groups people can read.