Adrienne Vermorel
Defining Metrics in dbt: Best Practices and Patterns
You’ve set up your semantic models in dbt with entities, dimensions, and measures. Now comes the part where most teams get stuck: writing metrics that actually work.
The good news? Metric design follows predictable patterns. Once you understand the five metric types and a few organizational principles, you can express almost any business calculation. This tutorial walks through each metric type with practical examples, then covers the naming conventions and organizational patterns that keep metrics maintainable as your project grows.
The Five Metric Types
MetricFlow supports five metric types. Each serves a specific purpose, and choosing the right one determines whether your calculation is correct.
Simple Metrics
Simple metrics reference a single measure with one aggregation. They’re the building block for everything else.
metrics: - name: order_total label: "Total Order Value" description: "Sum of all order values" type: simple type_params: measure: order_valueThat’s it. The measure order_value already defines its aggregation (likely sum), so the metric just references it. Use simple metrics for straightforward counts and sums: total revenue, order count, active users.
Cumulative Metrics
Cumulative metrics aggregate over time windows. Think weekly active users, month-to-date revenue, or rolling 30-day averages.
metrics: - name: weekly_active_users label: "Weekly Active Users" description: "Unique users active in the past 7 days" type: cumulative type_params: measure: active_users window: 7 daysTwo parameters control the behavior:
windowcreates a sliding window (7 days, 30 days)grain_to_dateresets at period boundaries (month-to-date, year-to-date)
metrics: - name: mtd_revenue label: "Month-to-Date Revenue" type: cumulative type_params: measure: revenue grain_to_date: monthOne requirement catches people off guard: queries using cumulative metrics with window must include metric_time as a dimension. Without a time dimension, the sliding window has no anchor point.
Derived Metrics
Derived metrics perform calculations using other metrics. They’re essential for profit margins, growth rates, and period-over-period comparisons.
metrics: - name: gross_profit label: "Gross Profit" type: derived type_params: expr: revenue - cost_of_goods_sold metrics: - name: revenue - name: cost_of_goods_soldThe expr parameter accepts any valid SQL expression using the referenced metric names. For period-over-period calculations, use offset_window with an alias:
metrics: - name: revenue_growth_wow label: "Revenue Growth % W/W" type: derived type_params: expr: (revenue - revenue_last_week) / revenue_last_week * 100 metrics: - name: revenue - name: revenue offset_window: 7 days alias: revenue_last_weekThe alias lets you reference the same metric at different time offsets within one expression.
Ratio Metrics
Ratio metrics divide a numerator by a denominator. Why not use a derived metric? Because ratios have a mathematical trap: the sum of ratios isn’t the ratio of sums.
If Store A has 50% conversion rate and Store B has 25% conversion rate, the combined rate isn’t 37.5%. It depends on the volume at each store. Ratio metrics handle this correctly by summing numerator and denominator separately before dividing.
metrics: - name: conversion_rate label: "Conversion Rate" type: ratio type_params: numerator: conversions denominator: sessionsYou can apply filters to just the numerator or denominator:
metrics: - name: mobile_conversion_rate label: "Mobile Conversion Rate" type: ratio type_params: numerator: name: conversions filter: - "{{ Dimension('session__device_type') }} = 'mobile'" denominator: sessionsConversion Metrics
Conversion metrics track when a base event leads to a conversion event within a time window. Think funnel analysis: visits to purchases, signups to activations.
metrics: - name: visit_to_purchase_rate label: "Visit to Purchase Rate" type: conversion type_params: entity: user calculation: conversion_rate base_measure: visits conversion_measure: purchases window: 7 daysThe entity parameter defines the join key linking base and conversion events. The calculation parameter can be conversion_rate (percentage) or conversions (count).
For stricter matching, constant_properties ensures attributes match between events:
type_params: entity: user calculation: conversion_rate base_measure: visits conversion_measure: purchases window: 7 days constant_properties: - base_property: "{{ Dimension('visit__device_type') }}" conversion_property: "{{ Dimension('purchase__device_type') }}"This counts a conversion only if the user’s device type matches between the visit and purchase.
Naming Conventions That Scale
Consistent naming makes metrics discoverable.
Names vs Labels
The name field is for code. The label field is for humans. Keep them distinct:
name: revenue_growth_momlabel: "Revenue Growth % M/M"Names use snake_case, all lowercase. Labels use proper capitalization and can include symbols like %.
Patterns by Metric Type
Different metric types benefit from different naming patterns:
| Type | Pattern | Example |
|---|---|---|
| Simple | {noun}_{aggregation} | order_count, revenue_sum |
| Cumulative | {time_period}_{metric} | weekly_active_users, mtd_revenue |
| Derived | {metric}_growth_{period} | revenue_growth_mom, orders_growth_yoy |
| Ratio | {numerator}_per_{denominator} | revenue_per_customer, orders_per_session |
| Conversion | {action}_to_{action}_rate | visit_to_buy_rate, signup_to_activate_rate |
Be Specific
Vague names cause confusion. revenue could mean gross revenue, net revenue, or adjusted revenue. response_time could be milliseconds or seconds.
Better:
gross_revenuenet_revenue_after_refundsresponse_time_secondsresponse_time_p95_ms
Group Related Metrics
Use consistent prefixes to group related metrics:
# Revenue familyrevenue_totalrevenue_per_orderrevenue_growth_momrevenue_mtd
# Customer familycustomer_countcustomer_lifetime_valuecustomer_acquisition_costcustomer_retention_rateWhen someone searches for “revenue,” they find all revenue-related metrics together.
Organizing Metrics in Large Projects
Small projects can define semantic models and metrics in the same file. Large projects need more structure.
Co-located Structure
For projects with fewer than 20 metrics, keep everything together:
models/ marts/ mrt__finance__orders.sql mrt__finance__orders.yml # semantic model + metrics mrt__sales__customers.sql mrt__sales__customers.yml # semantic model + metricsThe YAML file contains both the semantic model definition and any metrics built from it.
Parallel Sub-folder Structure
For larger projects, separate semantic models from metrics and organize by domain:
models/ marts/ mrt__finance__orders.sql mrt__sales__customers.sql semantic_models/ orders.yml customers.yml metrics/ revenue_metrics.yml customer_metrics.yml conversion_metrics.ymlThis structure scales because metrics often span multiple semantic models. A customer_lifetime_value metric might reference measures from both orders and customers semantic models. Putting it in a dedicated metrics folder avoids arbitrary placement decisions.
One Primary Entity Per Semantic Model
Each semantic model should have exactly one primary entity. This constraint keeps the semantic graph navigable.
semantic_models: - name: orders defaults: agg_time_dimension: ordered_at model: ref('mrt__finance__orders') entities: - name: order type: primary - name: customer type: foreign - name: product type: foreignThe primary entity (order) identifies what each row represents. Foreign entities (customer, product) enable joins to other semantic models.
Complex Metric Patterns
Real-world metrics require more than simple aggregations.
Period-Over-Period Comparisons
The offset_window parameter shifts a metric back in time:
metrics: - name: bookings_vs_last_week label: "Bookings Change vs Last Week" type: derived type_params: expr: bookings - bookings_7_days_ago metrics: - name: bookings - name: bookings offset_window: 7 days alias: bookings_7_days_agoFor percentage change:
expr: (bookings - bookings_7_days_ago) / NULLIF(bookings_7_days_ago, 0) * 100The NULLIF prevents division by zero when last week had no bookings.
Filtered Metrics
Apply filters using Jinja templating:
metrics: - name: enterprise_revenue type: simple type_params: measure: revenue filter: - "{{ Dimension('customer__segment') }} = 'enterprise'"For time dimensions with specific granularity:
filter: - "{{ TimeDimension('order__ordered_at', 'month') }} >= '2024-01-01'"Handling Nulls in Time Series
Metrics with no data for a time period return null, which creates gaps in charts. Two parameters fix this:
type_params: measure: name: revenue fill_nulls_with: 0 join_to_timespine: truejoin_to_timespine: true ensures every date appears in results. fill_nulls_with: 0 replaces nulls with zeros. Together, they produce complete time series without gaps.
Testing and Validation
MetricFlow validates configurations at three levels:
- Parsing validation: Does the YAML follow the schema?
- Semantic validation: Are names unique? Do references exist? Is there exactly one primary entity?
- Data platform validation: Do the referenced columns exist in physical tables?
Run all validations with:
# dbt Clouddbt sl validate
# dbt Coremf validate-configsAdd --verbose-issues --show-all for detailed output when debugging failures.
CI Integration
Add validation to your CI pipeline to catch breaking changes:
- name: Validate semantic layer run: dbt sl validateThis prevents merging PRs that break metric definitions.
Anti-Patterns to Avoid
One-Off Models for Metrics
Don’t create a new dbt model just to define a metric:
-- Bad: models/metrics/monthly_revenue.sqlSELECT DATE_TRUNC('month', ordered_at) AS month, SUM(amount) AS monthly_revenueFROM {{ ref('mrt__finance__orders') }}GROUP BY 1This duplicates transformation logic. Instead, define the metric on your existing orders mart model and let MetricFlow handle the aggregation.
Sum of Ratios
Never average percentages or rates:
# Wrong: will give mathematically incorrect results- name: avg_conversion_rate type: simple type_params: measure: conversion_rate # This is already a percentage per rowUse a ratio metric with separate numerator and denominator measures. The semantic layer computes the ratio after aggregating the components.
Hardcoded Filters in Measures
Measures with embedded filters reduce flexibility:
# Inflexiblemeasures: - name: enterprise_revenue expr: CASE WHEN segment = 'enterprise' THEN amount END agg: sumBetter to define a general measure and apply filters at the metric level:
measures: - name: revenue expr: amount agg: sum
metrics: - name: enterprise_revenue type: simple type_params: measure: revenue filter: - "{{ Dimension('customer__segment') }} = 'enterprise'"Now you can create smb_revenue, startup_revenue, or any other segment without defining new measures.
Missing Descriptions
Metrics without descriptions become mysteries:
# What does this measure?- name: arr type: simple type_params: measure: arr_valueAlways include description and label:
- name: arr label: "Annual Recurring Revenue" description: "Sum of annualized contract values for active subscriptions, excluding one-time fees" type: simple type_params: measure: arr_valueSix months from now, someone will thank you.
What to Do Next
Simple metrics for your core business measures (revenue, orders, users) are the right starting point. Get those working in your BI tools before adding complexity.
From there, a single derived metric like a period-over-period comparison or growth rate will exercise the offset_window pattern that handles most reporting needs.
Your existing dbt models likely contain embedded business logic worth migrating: hardcoded filters, calculated percentages, and pre-aggregated tables are all candidates for proper semantic layer metrics.
You don’t need every possible metric defined upfront. A few well-structured examples give your team patterns to follow as new requirements emerge.