Writing formulas
Every property and metric in the data model is one formula. The language is deliberately small: Excel-like functions over namespaced references, compiled by the engine into governed, dialect-correct SQL for your warehouse. The goal is a definition a teammate can read and sign off on in one glance.
All members can browse definitions and use the live preview; authoring requires the Data modeler role (see Roles and permissions).
References and the three grains
Every reference is written namespace.field, and the namespace tells you its grain:
| Reference | Grain | Example |
|---|---|---|
event.column | one event row | purchase.paid_usd |
user.property | one user, one day | user.revenue |
source.measure | one pre-aggregated row (date × dims) | ua_spend.cost |
metric.Name | aggregate | metric.Revenue |
The source.measure form references a pre-aggregated source (UA / cost data), whose id is the namespace just as an event’s id is. Its measures and dimensions read like any other column, which is what lets a metric span your user data and a cost feed in one line (metric.CPI = metric.Cost / metric.Installs).
The left-hand side of a definition declares what you are producing (user. or metric.), and aggregation functions reduce from the argument’s grain to that output grain. That one rule makes every formula unambiguous:
user.revenue = SUM(purchase.paid_usd) # event rows -> per user per day
metric.Revenue = SUM(user.revenue) # per user per day -> aggregate
Properties
Properties are per-user, per-day facts. The common forms:
Aggregate an event (with optional row filter and default):
user.revenue = SUM(purchase.paid_usd)
user.paid_sessions = COUNT(session where session.revenue > 0)
user.active = EXISTS(activity)
user.last_country = LAST(session.country)
Aggregate over time with over. Rolling windows, fixed ranges, and lifetime are one family:
user.revenue_7d = SUM(user.revenue over last 7 days)
user.q1_revenue = SUM(user.revenue over [2024-01-01 .. 2024-03-31])
user.revenue_ltv = SUM(user.revenue over lifetime)
user.peak_level = MAX(user.level over lifetime)
over lifetime means “all days up to each day”, resolved as of that day, which is what makes these properties point-in-time correct by construction.
Values captured at registration stay frozen at the user’s day 0:
user.country = entity_registrations.country
Row formulas combine other properties with no aggregation, staying per-user-day. AS_OF is the row’s date:
user.is_payer = user.revenue_ltv > 0
user.days_since_active = DAYS_BETWEEN(AS_OF, user.last_active)
Buckets map values to named tiers, the workhorse of F2P segmentation:
user.spender_tier = BUCKET(user.revenue_ltv):
0 -> "non-payer"
[1 .. 50) -> "minnow"
[50 .. 500) -> "dolphin"
else -> "whale"
Metrics
Metrics aggregate properties across users, and can reference each other:
metric.Revenue = SUM(user.revenue) unit "$" prefix
metric.DAU = SUM(user.active)
metric.ARPDAU = metric.Revenue / metric.DAU unit "$" prefix
metric.Retention = SUM(user.active) / SUM(user.cohort_size) by [cohort_day] as percent
by [cohort_day] pins a metric to an axis when it is only meaningful there (retention curves plot over days since registration); metrics without by slice freely at query time. as percent and unit control display.
Division is always safe
Write plain /. The engine stores and executes it as null-safe division for your warehouse’s dialect (SAFE_DIVIDE on BigQuery, guarded division elsewhere), so a zero denominator yields null instead of a failed query. You never see or write the ugly form; the safe default is invisible, and the readable line is the definition.
Modifiers
| Modifier | Does |
|---|---|
label "…" / describe "…" | Display name and description (these surface in dashboards and AI chat) |
where <predicate> | Filters the rows or users that count |
default <v> | Value for user-days with no matching events |
unit "$" prefix, as percent | Display units |
by [dims] | Pins a metric’s axis |
nofilter / nogroupby | Opt a property out of filtering / grouping (both are offered by default) |
hidden | Keeps a property out of the query UI entirely (used only inside other definitions) |
Why it looks like this
A definition like metric.ARPDAU = metric.Revenue / metric.DAU reads at the altitude of the business idea, so agreeing on a definition is a review, not an archaeology project. The engine owns the parts below the surface (null handling, timezones, cohort-day math, dialect differences), and the same named definitions ground every dashboard and every AI chat answer. One line to verify, one meaning everywhere.