Skip to main content
On this page

Model

Why Kater SQL

Kater models are SQL with a few additions. See what each line becomes on live definition cards, and why Kater uses SQL and not YAML.

Audience
Data teams, and anyone who chooses a semantic layer
Kater models are SQL. You write the SELECT you already write, and add a few calls that name what matters: a measure, a rule, a join. The compiler reads the SQL and makes a definition card for every field. Each card on this page is the compiler’s own card for a field of Kater’s sample shop. Open a row under Use to see the query it writes and the rows it gives.

What it gives your team

You wantKater SQL gives youSee
One Revenue for finance, marketing and every agentOne line of SQL that every query, dashboard, app and answer readsA measure is one line of SQL
Each person to answer their own questionOne definition that each reader evaluates, passes on or builds onThe reader decides how to use it
A new metric the day you need itA measure that starts in your own query, and moves up as the same line when it proves usefulBuild a measure from measures
Numbers that are never wrong in a quiet wayRules that the compiler checks on every query, such as one currency per rowRules travel with the measure
Fast answers from agents that you can trustSQL that agents already write well, checked by the same compilerBuilt for agents

A measure is one line of SQL

Finance and marketing both report Revenue. In Kater it is one line of the Orders dataset:

modeling/kater_objects/datasets/orders.sql
SELECT
DIMENSION('order_id', orders.order_id) AS "Order ID",
DIMENSION('region', orders.region) AS "Region",
DIMENSION('currency', orders.currency) AS "Currency",
DIMENSION('amount', orders.amount) AS "Amount",
-- ...
-- @description Order amount before discounts.
MEASURE('sum__amount', KATER__SUM(orders.amount), NOT_ADDITIVE_ACROSS(currency)) AS "Revenue", /* @format currency(currency, 2) */
-- ...
FROM shop.sales.orders AS orders

Each part of those two lines becomes a part of its card:

You writeThe card shows
MEASURE('sum__amount', …) AS "Revenue"The name, Revenue, and the address other models read, orders.sum__amount
KATER__SUM(orders.amount)Expression SUM(amount), and From orders › amount
NOT_ADDITIVE_ACROSS(currency)Required: one Currency per result row
-- @descriptionThe line under the name
/* @format currency(currency, 2) */Format/Display: 2 decimal places, with the symbol of each row’s currency
NothingGroup or filter by: the dimensions of the dataset. Use: each way to read it.

The frontmatter at the top of a file holds the model’s name and label. Every field is in the SQL. There is no second file to keep in step with it.

Write a measure

MEASURE('name', aggregation, NOT_ADDITIVE_ACROSS(dimension)) AS "Label"
  • 'name' is its identity. Other models read it by this name. AS gives it a label only.
  • The aggregation is SQL. Use a KATER__ aggregation, such as KATER__SUM(orders.amount), or a formula of other measures, each in EVAL.
  • NOT_ADDITIVE_ACROSS is optional. It names the dimensions that each result row must hold to one value.
  • Declare it in the outermost SELECT. Never in a CTE or a subquery.
  • Name it only when another model reads it. In a query, a value that only that query shows can stay unnamed.

See Measures for every aggregation, and Formats, descriptions and AI context for the comments.

The reader decides how to use it

The data team defines Revenue one time. Each person who reads it decides what it means in their analysis. The card’s Use section lists the choices, each with the query it writes. On the card above, open Share of total, then select Preview data.

UseYou writeYou get
EvaluateEVAL(orders.sum__amount)Revenue at this query’s grouping, as a value fixed at that grain
Pass throughorders.sum__amountThe same measure. A query that reads yours works it out again at its own grouping.
Build on topKATER__SHARE_OF_TOTAL(EVAL(orders.sum__amount)) OVER (PARTITION BY orders.currency)A new field, such as each row’s share of the total in its currency
  • Each Build on top row is one call. A running total, a rank, a share of total or a change from the day before.
  • The rule comes along. Kater writes the PARTITION BY that the currency rule needs, so a share is never a share of euros and dollars together.
  • The new field has a card too. Open Share of total and read Definition under its rows: it is worked out over the query’s rows, separately for each currency.

See Pass through, evaluate or declare.

Build a measure from measures

Marketing wants revenue per customer. A query of the Shop workspace declares it from two measures:

workspaces/shop/query_contracts/region_mix.sql
SELECT
shop.region_group,
shop.country,
shop.currency,
shop."loyalty tier",
MEASURE(
'revenue_per_customer',
EVAL(shop.sum__amount) / NULLIF(EVAL(shop.count_distinct__customer_id), 0)
) AS "Revenue per Customer"
FROM workspace.shop AS shop
-- ...
  • MEASURE names it. A query that reads it works it out again at its own grouping, from the two totals. It is never an average of rows.
  • EVAL computes each measure inside the formula. Revenue and Customers each keep their own definition.

Its Inputs say what a reader can do with it:

RowWhat it saysFor Revenue per Customer
Group or filter byThe dimensions a query can group it by or filter it byAny of 4: Region Group, Country, Currency and Loyalty Tier
RequiredWhat each query that reads it must doHold one currency per result row, from Revenue
Not allowedDimensions outside its rows, which a query cannot group it byNone
Optional filtersFilters a reader can turn onCountry

A measure like this often starts in one person’s query. When it proves useful, you promote it to the workspace, then to the shared layer. It moves as the same line, and its card stays the same. See How a field moves up.

Rules travel with the measure

A marketer asks for revenue by region. The query has no currency:

workspaces/shop/query_contracts/revenue_by_region.sql
SELECT
shop.region,
shop.sum__amount
FROM workspace.shop AS shop
GROUP BY
shop.region

The compiler refuses it with not_additive_across: “Revenue cannot be added up across Currency. Group by Currency or filter it to one value.” The EMEA row would add euros to dollars. The quick fix, Group by Currency, adds shop.currency to the SELECT and the GROUP BY.

Nobody wrote this rule in the query. It is part of Revenue, so it reaches every query that reads Revenue, every measure built on it, such as Revenue per Customer, and every query an agent writes. The compiler checks other rules the same way:

CodeWhat it stops
not_additive_acrossA total of values that cannot be added up, such as two currencies
repeated_formulaA second formula for something the workspace already computes
name_reusedOne name for two different formulas
measure_value_declared_as_dimensionA measure’s value declared as a dimension

See Measures you cannot add up and Diagnostics and fixes.

Joins count each row once

Customers is declared on the Customers dataset, one row for each customer. The Shop model joins each order to its customer, and passes Customers through:

modeling/kater_objects/semantic_models/shop.sql
SELECT
o.order_id,
o.region,
-- ...
c.count_distinct__customer_id,
-- ...
FROM kater_dataset.orders AS o
LEFT JOIN RELATIONSHIP('customer', kater_dataset.customers AS c ON o.customer_id = c.customer_id, 'many_to_one')
WHERE
REQUIRED_FILTER('is_not_null__order_id', KATER__IS_NOT_NULL(o.order_id), label='Order ID is set')

The card names the join and the filter under Added here:

Group Customers by an order’s region, and each customer counts once in each region they ordered in:

RegionCustomers
AMER232
APAC229
EMEA231
LATAM228
null56

The rows add up to 976, but the shop has 240 customers. Ask for Customers with no grouping, and Kater counts the distinct customers again from the order rows: 240. A join never repeats a measure’s rows, so a total is never a sum of other totals. See Measures across joins and unions.

KATER__ functions say what they mean

The Shop model groups regions with KATER__MAP:

modeling/kater_objects/semantic_models/shop.sql
DIMENSION(
'region_group',
KATER__MAP(o.region, ('EMEA', 'Europe'), ('AMER', 'Americas'), default_label => 'Rest of World')
) AS "Region Group",

Open Step by step on its card. Kater reads the call and writes each step in words.

  • One meaning on every warehouse. The compiler turns each KATER__ call into Snowflake SQL or DuckDB SQL.
  • The menus write them. A field’s menu, the filter bar and Use write KATER__ calls, and the card reads them back.
  • A dimension has its own Use rows. Group by it, filter by it, or make a flag from one of its values.
Instead ofWrite
DATE_TRUNC('month', d)KATER__MONTH(d)
A self-join or LAG for the month beforeKATER__PREVIOUS_PERIOD(m, by => KATER__MONTH(d))
ROW_NUMBER() with QUALIFY ... <= 10KATER__IS_IN_TOP(EVAL(m), 10)
d >= DATEADD(month, -12, ...)KATER__IS_WITHIN_LAST(d, 12, 'month', complete => TRUE)
CASE to give values display namesKATER__MAP(platform, ('google_ads', 'Google Ads'), ...)

See KATER__ functions for each one, with its arguments and an example.

Why SQL and not YAML

Most semantic layers keep metrics in YAML files beside the SQL that computes them. Kater keeps them in the SQL:

A YAML semantic layerKater SQL
Where a metric livesA YAML entry that points at SQLThe SQL itself
A joinDeclared in YAML, apart from the SQL that uses itRELATIONSHIP(...) in the FROM
What a metric can useWhat the YAML schema has a key forYour warehouse’s SQL: joins, CASE, CTEs, window functions and its own functions
A changeTwo files that must agreeOne file, compiled on every save
A metric from an analysisWritten again as YAMLPromoted as the same line
What a person or an agent learnsA new schemaSQL, with a few calls

We chose SQL for these reasons:

  • One place for the logic. A metric, its joins and its rules are in one file. You read it, compare it, test it and review it like any other SQL.
  • You never leave the model. When a metric needs a window, a CTE or a function of your warehouse, you write it in the model. It keeps its card, its rules and its lineage.
  • Definitions come out of the work. The compiler finds the definitions in every model and every query. A measure that an analyst writes in a query has a card when it is saved. When two queries write the same formula, Kater recommends a promotion.
  • Every editor writes the same file. The portal, VS Code, the CLI and your agent all write the same SQL through the same operations, and each save is compiled.

Built for agents

Language models write very good SQL. Kater SQL is SQL, so an agent writes it the way your team does, with no new language to learn.

  • The compiler checks every write. An agent’s query meets the same rules as yours: the currency rule, the joins and the names. A wrong total is refused before anyone reads it.
  • The agent reads the card you read. The Kater MCP’s explain_field tool gives the agent the same definition as the cards on this page. It knows what Revenue means before it uses Revenue.
  • The Use rows are its operations. An agent builds a query from each field’s operations, the same ones Use lists. Asked for each region’s share of revenue, it applies Share of total, and Kater writes the call with its PARTITION BY.
  • Every answer reads your definitions. An agent does not make a new formula for Revenue from the tables. It reads the measure that your data team defined, so its answer matches your dashboards.

See Agents pick fields first on the Queries page, and Kater MCP.

Next steps