Skip to content

Semantic Layer & Metrics

Define each business metric once, in code, so every dashboard, notebook and AI assistant computes "revenue" the same way.

Last reviewed · Download PDF

Prerequisites: SQL · Data Modeling · dbt

Related: Data Quality · Governance & Lineage · BI Tools · Glossary


Overview

Challenge: Ask three teams for "revenue last month" and you get three numbers. Finance excludes refunds, marketing counts orders at checkout, and a dashboard author filtered out a test country years ago. The logic lives in dozens of BI tool formulas and copy-pasted SQL, and nobody can say which is right.

Solution: A semantic layer sits between the warehouse tables and everything that consumes them. It holds the definitions of metrics (revenue, active users, conversion rate) and dimensions (date, country, product category) once, as versioned code. Consumers ask for "revenue by month by country" and the layer generates the correct SQL, with the right joins, filters and aggregation.

flowchart LR
    T[("Warehouse tables<br/>fct_orders, dim_customers")] --> SL["Semantic layer<br/>metrics + dimensions<br/>defined once, in Git"]
    SL --> BI["BI tool"]
    SL --> NB["Notebooks / Python"]
    SL --> API["APIs / apps"]
    SL --> AI["AI assistants<br/>(natural-language questions)"]

Relevance to data engineering: Data engineers own the definitions that everyone else trusts. A semantic layer moves business logic out of dashboards and into the same reviewed, tested, versioned codebase as the transformations. It is also what makes AI-generated SQL safer: the model asks for named metrics instead of inventing its own SUM(...).


On this page

Basic - The Problem in SQL - Core Concepts

Intermediate - Defining Metrics with dbt MetricFlow - Metric Types - Querying Metrics

Advanced - Where the Layer Lives - Semantic Layer for AI and Natural Language - Testing and Governing Metrics

Reference - Common Pitfalls - Cheat Sheet - Interview Questions - Further Reading


The Problem in SQL

Both queries below are "revenue by month", written by two reasonable people:

-- Dashboard A (finance): completed orders only, net of refunds
SELECT date_trunc('month', order_date) AS month,
       SUM(amount - refunded_amount)    AS revenue
FROM fct_orders
WHERE status = 'completed'
GROUP BY 1;

-- Dashboard B (marketing): every order placed
SELECT date_trunc('month', order_date) AS month,
       SUM(amount)                      AS revenue
FROM fct_orders
GROUP BY 1;

Both are called "revenue", they disagree every month, and only careful reading of the SQL shows why. A semantic layer makes the definition a named, single artifact (revenue = completed orders, net of refunds) that both dashboards must use, and it forces a second, deliberately named metric (gross_bookings) for the marketing view.

The same trap appears with non-additive metrics. You cannot sum averages or distinct counts across groups: the "average order value by country" of three countries is not the average of the three averages. A semantic layer keeps the numerator and denominator and computes the ratio at the requested grain.


Core Concepts

Concept Meaning Example
Entity A key that joins tables together order_id, customer_id
Dimension An attribute you group or filter by order_date, country, status
Measure An aggregation over a column, the building block of a metric SUM(amount), COUNT(DISTINCT customer_id)
Metric A business definition built from measures, with filters and rules revenue, conversion_rate
Grain What one row of a table represents one row per order line
Time dimension The date field a metric is aggregated over, at day, week, month... metric_time

Getting the grain right is the most common source of wrong numbers. A join from orders (one row per order) to order items (many rows per order) multiplies an order-level amount by the number of items unless the layer aggregates each table to its own grain first. Semantic layers exist largely to avoid this fan-out.


Defining Metrics with dbt MetricFlow

The dbt Semantic Layer is powered by MetricFlow. You describe a semantic model on top of a dbt model, then define metrics that use its measures.

# models/marts/semantic_orders.yml
semantic_models:
  - name: orders
    description: One row per order
    model: ref('fct_orders')
    defaults:
      agg_time_dimension: order_date

    entities:
      - name: order_id
        type: primary
      - name: customer
        type: foreign
        expr: customer_id

    dimensions:
      - name: order_date
        type: time
        type_params:
          time_granularity: day
      - name: status
        type: categorical
      - name: country
        type: categorical

    measures:
      - name: order_total
        description: Order value before refunds
        agg: sum
        expr: amount
      - name: order_count
        agg: count
        expr: order_id
      - name: refunds
        agg: sum
        expr: refunded_amount

metrics:
  - name: gross_bookings
    label: Gross bookings
    type: simple
    type_params:
      measure: order_total

  - name: revenue
    label: Revenue
    description: Completed orders, net of refunds
    type: simple
    type_params:
      measure: order_total
    filter: |
      {{ Dimension('order__status') }} = 'completed'

The metric specification is still evolving, and newer dbt versions accept a simplified format, so check the dbt documentation for the version you run. The concepts (entities, dimensions, measures, metrics) stay the same.


Metric Types

Type What it computes Example
Simple One measure, optionally filtered gross_bookings
Ratio Numerator metric ÷ denominator metric, computed at the queried grain refund_rate = refunds / gross_bookings
Derived An expression over other metrics net_revenue = gross_bookings - refunds
Cumulative A running total or a rolling window revenue_last_30_days
Conversion Share of entities that did B after A within a window signup_to_purchase
metrics:
  - name: refunds_total
    label: Refunds
    type: simple
    type_params:
      measure: refunds

  - name: refund_rate
    label: Refund rate
    type: ratio
    type_params:
      numerator: refunds_total
      denominator: gross_bookings

  - name: revenue_30d
    label: Revenue, rolling 30 days
    type: cumulative
    type_params:
      measure: order_total
      window: 30 days

Ratio metrics are why a semantic layer is worth having: averaging pre-computed daily rates gives a wrong monthly rate, while the layer recomputes numerator and denominator at the month grain.


Querying Metrics

With the dbt-metricflow package installed, query from the command line:

mf validate-configs                                              # check definitions and warehouse access
mf list metrics
mf query --metrics revenue --group-by metric_time__month,order__country --order metric_time__month
mf query --metrics revenue,refund_rate --group-by metric_time__month --explain   # show the SQL it generates

--explain is the best way to learn how the layer plans joins and grain. In production, consumers use the Semantic Layer APIs (JDBC and GraphQL) from dbt Cloud, so BI tools and notebooks select metrics by name instead of writing SQL by hand.

The idea works without any product. Even a plain SQL view per metric, owned and tested in Git, removes most of the inconsistency:

-- One reviewed definition, reused everywhere
CREATE VIEW metrics.revenue_daily AS
SELECT order_date,
       country,
       SUM(amount - refunded_amount) AS revenue,
       COUNT(*)                      AS orders
FROM fct_orders
WHERE status = 'completed'
GROUP BY order_date, country;

Store the additive parts (sum and count), never a pre-computed average, so any consumer can re-aggregate correctly: SUM(revenue) / SUM(orders) at whatever grain it needs.


Where the Layer Lives

Option How it works Trade-offs
Transformation-layer (dbt Semantic Layer / MetricFlow) Metrics live next to dbt models, exposed through APIs One definition for many tools. Needs the dbt platform for the APIs
Standalone headless BI (Cube, and similar) A dedicated service with its own modelling language, APIs and caching Rich caching and access control, but another system to run
BI tool (LookML in Looker, Power BI semantic models, Tableau) Definitions live in the BI tool Great inside that tool, but do not follow the metric into other tools
Warehouse-native (Snowflake semantic views, Databricks metric views) The warehouse stores metric definitions that its SQL and BI connectors can query Close to the data. Portability depends on the vendor
Plain SQL views Reviewed view per metric Simplest. No automatic joins or ratio handling

Choose by how many consumers you have. With one BI tool, its native layer is the least work. With several tools, Python users and an AI assistant, a definition outside any single tool pays off.


Semantic Layer for AI and Natural Language

Asking a language model to write SQL directly against raw tables produces plausible queries with wrong logic: the wrong join, the wrong status filter, an average of averages. A semantic layer narrows the model's job to choosing metrics and dimensions from a fixed, documented list, while the layer generates the SQL.

flowchart LR
    Q["'Revenue by country<br/>last quarter?'"] --> M["LLM selects:<br/>metric = revenue<br/>group_by = country<br/>time = last quarter"]
    M --> SL["Semantic layer<br/>compiles governed SQL"]
    SL --> W[("Warehouse")]
    W --> A["Answer + the<br/>metric definition used"]

Good practice: - Give every metric and dimension a clear description and label. The model reads these, so they are effectively its documentation. - Return the definition used alongside the number, so users can see that "revenue" means completed orders net of refunds. - Evaluate the assistant on a fixed set of questions with known answers before releasing it. See Evals.


Testing and Governing Metrics

Treat metric definitions as code:

  • Review changes in pull requests, with a named owner per metric (meta: {owner: finance-data}).
  • Validate in CI (mf validate-configs for MetricFlow) so a renamed column fails the build, not the dashboard.
  • Reconcile against a trusted source: a test that the semantic layer's monthly revenue equals the finance system's number within a tolerance. See Data Quality.
  • Version breaking changes. If "revenue" is redefined, publish revenue_v2, migrate consumers, then retire the old one, and record the change so historical reports can be explained. See Governance & Lineage.
  • Deprecate duplicates: when a new metric replaces several dashboard formulas, delete the formulas.

Common Pitfalls

Pitfall Symptom Fix
Storing pre-computed ratios or averages Monthly numbers do not match a re-aggregation of daily numbers Store additive parts (sums and counts) and compute the ratio at query time
Join fan-out from mixed grains Revenue inflated by the number of line items Aggregate each table to its own grain first, or use a layer that plans joins
Two metrics with the same name in different tools Numbers disagree again One source of truth, and remove or redirect the duplicates
No owner or description Nobody trusts or can safely change a metric Require description and an owner on every metric
Redefining a metric silently Historical dashboards jump Version the metric and communicate the change
Modelling everything up front Months spent, nothing used Start with the ten metrics leadership asks for, then extend
Treating the layer as a replacement for good models Slow, confusing queries Keep clean, tested marts underneath. The layer sits on top of them

Cheat Sheet

Task Command / Syntax
Define a metric metrics: block with name, type, type_params
Semantic model semantic_models: with model: ref(...), entities, dimensions, measures
Filter a metric filter: "{{ Dimension('order__status') }} = 'completed'"
Validate mf validate-configs
List metrics mf list metrics
Query mf query --metrics revenue --group-by metric_time__month
See the SQL add --explain
Additive storage rule keep SUM and COUNT, derive AVG and rates at query time

Interview Questions

Q: What problem does a semantic layer solve? A: Metric drift: the same word ("revenue", "active user") calculated differently in every dashboard and notebook. By defining metrics once, in version-controlled code, with the joins, filters and grain handled centrally, every consumer gets the same answer and a change is made in one reviewed place.

Q: Why can't you just average daily conversion rates to get a monthly rate? A: A rate is a ratio, and an average of ratios weights every day equally regardless of volume. The correct monthly rate is total conversions divided by total visits. Semantic layers keep the numerator and denominator and compute the ratio at the requested grain.

Q: What is fan-out and how do you avoid it? A: Joining a table to another at a finer grain repeats the coarser table's values, so summing them overcounts. Avoid it by aggregating each table to the target grain before joining, by measuring each metric on the table at its own grain, or by using a layer that plans the joins.

Q: How does a semantic layer help with AI-generated analytics? A: It restricts the model to a governed vocabulary of metrics and dimensions. The model chooses what to ask for, and deterministic code writes the SQL, which avoids invented joins and inconsistent filters. You can also show the user the definition behind each answer.

Q: Where should the semantic layer live? A: As close to the data and as tool-neutral as your consumers require. One BI tool: use its native layer. Several tools and programmatic or AI consumers: a transformation-layer or warehouse-native layer, so the definition does not depend on any single front end.


Further Reading


Previous: dbt · Next: BI Tools · Back to: Index