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 want
Kater SQL gives you
See
One Revenue for finance, marketing and every agent
One line of SQL that every query, dashboard, app and answer reads
Each part of those two lines becomes a part of its card:
You write
The card shows
MEASURE('sum__amount', …) AS "Revenue"
The name, Revenue, and the address other models read, orders.sum__amount
KATER__SUM(orders.amount)
ExpressionSUM(amount), and Fromorders › amount
NOT_ADDITIVE_ACROSS(currency)
Required: one Currency per result row
-- @description
The line under the name
/* @format currency(currency, 2) */
Format/Display: 2 decimal places, with the symbol of each row’s currency
Nothing
Group or filter by: the dimensions of the dataset. Use: each way to read it.
Revenue
orders.sum__amount
Description
Order amount before discounts.
Inputs
Group or filter by
Required
one Currency per result row: group by it or filter it to one value
Not allowed
none
Computes
Expression
SUM(amount)
From
orders › amount
Always filtered
none
Step by step
1
Sum of amount
Format/Display
•2 decimal places
•Currency symbol from each row's Currency, such as $ for USD
Use
EvaluateEVAL(orders.sum__amount)
Pass throughorders.sum__amount, or *
Build on top
Running totalCUSTOM_CALCULATION('running_sum__sum__amount', KATER__WINDOW_AGGREGATION(EVAL(orders.sum__amount), 'sum', 'running') OVER (PARTITION BY orders.currency ORDER BY orders.order_date ASC))
7-day averageCUSTOM_CALCULATION('trailing_7_avg__sum__amount', KATER__WINDOW_AGGREGATION(EVAL(orders.sum__amount), 'avg', 'trailing', 7) OVER (PARTITION BY orders.currency ORDER BY orders.order_date ASC))
Change from previous dayCUSTOM_CALCULATION('delta__sum__amount', KATER__DELTA(EVAL(orders.sum__amount)) OVER (PARTITION BY orders.currency ORDER BY orders.order_date ASC))
Percent change from previous dayCUSTOM_CALCULATION('percent_delta__sum__amount', KATER__PERCENT_DELTA(EVAL(orders.sum__amount)) OVER (PARTITION BY orders.currency ORDER BY orders.order_date ASC))
RankCUSTOM_CALCULATION('rank__sum__amount', KATER__RANK(EVAL(orders.sum__amount), 'desc') OVER (PARTITION BY orders.currency))
Share of totalCUSTOM_CALCULATION('share_of_total__sum__amount', KATER__SHARE_OF_TOTAL(EVAL(orders.sum__amount)) OVER (PARTITION BY orders.currency))
Total across all datesMEASURE('across_order_date__sum__amount', KATER__IGNORE_GROUPING_BY(orders.sum__amount, orders.order_date))
Reading the definition
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.
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.
Use
You write
You get
Evaluate
EVAL(orders.sum__amount)
Revenue at this query’s grouping, as a value fixed at that grain
Pass through
orders.sum__amount
The same measure. A query that reads yours works it out again at its own grouping.
Build on top
KATER__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.
Revenue divided by Customers, empty when equal to 0
•Orders › Sum of amount.
•Order amount before discounts.
•Customers › Count distinct customer id.
•Each qualifying value counts once within each group in your report; missing values do not count.
•Shop › Reexports the reusable measure Customers from Customers.
•Include rows where order id is present.
•Order amount before discounts.
•Shop › Reexports the reusable measure Customers from Shop.
•Include rows where all of the following apply:
- region is one of the values of the Region parameter when the Region filter is enabled
- order date is between the start of the Order Date parameter and the end of the Order Date parameter (inclusive) when the Order date filter is enabled
- amount is at least the Minimum amount parameter when the Minimum amount filter is enabled.
•Order amount before discounts.
•Region: default Not set.
•Order Date: default Not set.
•Minimum amount: default 0.
•Calculate once per shop.region_group, shop.country, shop.currency, shop."loyalty tier".
•Include rows where all of the following apply:
- country is the Country parameter when the Country filter is enabled
- channel contains 'paid', ignoring letter case.
•Country: default "US".
Use
EvaluateEVAL(region_mix.revenue_per_customer)
Pass throughregion_mix.revenue_per_customer, or *
Build on top
RankCUSTOM_CALCULATION('rank__revenue_per_customer', KATER__RANK(EVAL(region_mix.revenue_per_customer), 'desc') OVER (PARTITION BY region_mix.currency))
Share of totalCUSTOM_CALCULATION('share_of_total__revenue_per_customer', KATER__SHARE_OF_TOTAL(EVAL(region_mix.revenue_per_customer)) OVER (PARTITION BY region_mix.currency))
Total across all Region GroupMEASURE('across_region_group__revenue_per_customer', KATER__IGNORE_GROUPING_BY(region_mix.revenue_per_customer, region_mix.region_group))
Reading the definition
Its Inputs say what a reader can do with it:
Row
What it says
For Revenue per Customer
Group or filter by
The dimensions a query can group it by or filter it by
Any of 4: Region Group, Country, Currency and Loyalty Tier
Required
What each query that reads it must do
Hold one currency per result row, from Revenue
Not allowed
Dimensions outside its rows, which a query cannot group it by
None
Optional filters
Filters a reader can turn on
Country
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:
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:
Code
What it stops
not_additive_across
A total of values that cannot be added up, such as two currencies
repeated_formula
A second formula for something the workspace already computes
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:
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:
Customers
customers.count_distinct__customer_id
Inputs
Group or filter by
Required
none
Not allowed
none
Computes
Expression
COUNT(DISTINCT customer_id)
From
customers › customer_id
Always filtered
none
Added here
left join on customer_id = customer_id in Shop
NOT(order_id IS NULL) in Shop
Step by step
1
Count of distinct customer_id; missing values do not count
Data type
text → number
Each qualifying value counts once within each group in your report; missing values do not count.
Use
EvaluateEVAL(shop.count_distinct__customer_id)
Pass throughshop.count_distinct__customer_id, or *
Build on top
Running totalCUSTOM_CALCULATION('running_sum__count_distinct__customer_id', KATER__WINDOW_AGGREGATION(EVAL(shop.count_distinct__customer_id), 'sum', 'running') OVER (ORDER BY shop.order_date ASC))
7-day averageCUSTOM_CALCULATION('trailing_7_avg__count_distinct__customer_id', KATER__WINDOW_AGGREGATION(EVAL(shop.count_distinct__customer_id), 'avg', 'trailing', 7) OVER (ORDER BY shop.order_date ASC))
Change from previous dayCUSTOM_CALCULATION('delta__count_distinct__customer_id', KATER__DELTA(EVAL(shop.count_distinct__customer_id)) OVER (ORDER BY shop.order_date ASC))
Percent change from previous dayCUSTOM_CALCULATION('percent_delta__count_distinct__customer_id', KATER__PERCENT_DELTA(EVAL(shop.count_distinct__customer_id)) OVER (ORDER BY shop.order_date ASC))
RankCUSTOM_CALCULATION('rank__count_distinct__customer_id', KATER__RANK(EVAL(shop.count_distinct__customer_id), 'desc') OVER ())
Share of totalCUSTOM_CALCULATION('share_of_total__count_distinct__customer_id', KATER__SHARE_OF_TOTAL(EVAL(shop.count_distinct__customer_id)) OVER ())
Total across all datesMEASURE('across_order_date__count_distinct__customer_id', KATER__IGNORE_GROUPING_BY(shop.count_distinct__customer_id, shop.order_date))
Reading the definition
Group Customers by an order’s region, and each customer counts once in each region they ordered in:
Region
Customers
AMER
232
APAC
229
EMEA
231
LATAM
228
null
56
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
1
DIMENSION(
2
'region_group',
3
KATER__MAP(o.region,('EMEA','Europe'),('AMER','Americas'),default_label=>'Rest of World')
4
)AS"Region Group",
Open Step by step on its card. Kater reads the call and writes each step in words.
Region Group
shop.region_group
Computes
Expression
CASE WHEN region = 'EMEA' THEN 'Europe' WHEN region = 'AMER' THEN 'Americas' ELSE 'Rest of World' END
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 layer
Kater SQL
Where a metric lives
A YAML entry that points at SQL
The SQL itself
A join
Declared in YAML, apart from the SQL that uses it
RELATIONSHIP(...) in the FROM
What a metric can use
What the YAML schema has a key for
Your warehouse’s SQL: joins, CASE, CTEs, window functions and its own functions
A change
Two files that must agree
One file, compiled on every save
A metric from an analysis
Written again as YAML
Promoted as the same line
What a person or an agent learns
A new schema
SQL, 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.