Concepts

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:

ReferenceGrainExample
event.columnone event rowpurchase.paid_usd
user.propertyone user, one dayuser.revenue
source.measureone pre-aggregated row (date × dims)ua_spend.cost
metric.Nameaggregatemetric.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

ModifierDoes
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 percentDisplay units
by [dims]Pins a metric’s axis
nofilter / nogroupbyOpt a property out of filtering / grouping (both are offered by default)
hiddenKeeps 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.