The Data Brief · Issue #6 · Semantic Layer Series

Build it

From raw schema to working semantic layer — with the pitfalls nobody warns you about

Shrikant·April 29, 2026·10 min read

The previous five issues covered the why, the what, the which, and the design. This one covers the how.

We are going to build a semantic layer on a real-world schema — an e-commerce orders dataset — using dbt and MetricFlow, the most common production setup in 2026. By the end you will have a working semantic model, two governed metrics, a time spine, and a validated query. More importantly, you will have encountered the mistakes that send teams back to square one — before you make them in production.

What you need to follow along
A dbt project (v1.6 or higher) connected to Snowflake, BigQuery, Databricks, or Redshift
dbt Cloud Starter plan or higher — required for Semantic Layer APIs and BI integrations
For local testing without dbt Cloud: pip install dbt-metricflow
Source data: orders, customers, products tables (Jaffle Shop-style schema)

The architecture before you write a line of YAML

The correct layer order is: Sources → Staging → Marts → Semantic Models → Metrics → Consumption

The semantic layer must sit strictly above your mart models. Never build semantic models on top of staging or raw source tables — you will eventually need mart-level business logic and have nowhere clean to put it.

dbt best practice: prefer normalization when possible and allow MetricFlow to handle denormalization dynamically. Build in parallel with existing marts — never refactor production models directly. Audit the semantic layer output and deprecate old marts gracefully.


Step 1

Set up the time spine

10 min · skip this and every time-based metric fails

The time spine is not optional. Any project with time dimensions needs one. The error it produces — dimension not found — is not obviously connected to a missing time spine.

models/metricflow_time_spine.sql
{{ config(materialized='table') }} with days as ( {{ dbt.date_spine( 'day', "to_date('2020-01-01', 'YYYY-MM-DD')", "to_date('2030-01-01', 'YYYY-MM-DD')" }} ), final as ( select cast(date_day as date) as date_day from days ) select * from final where date_day > dateadd(year, -4, current_timestamp()) and date_day < dateadd(day, 30, current_timestamp())

Run dbt build to materialise the time spine before defining any semantic models.


Step 2

Define your first semantic model

20–30 min · faster once the pattern is set
models/semantic_models/sem_orders.yml
semantic_models: - name: sem_orders description: > Order-level fact table. One row per order. Excludes test orders. Uses order_date as primary time dimension. model: ref('fct_orders') defaults: agg_time_dimension: order_date entities: - name: order_id type: primary - name: customer_id type: foreign expr: customer_id dimensions: - name: order_date type: time type_params: time_granularity: day - name: order_status type: categorical expr: status - name: region type: categorical expr: customer_region measures: - name: order_revenue description: Gross order value excl. tax and refunds agg: sum expr: order_amount - name: order_count description: Count of distinct orders agg: count_distinct expr: order_id

Three things to get right. First: agg_time_dimension must exactly match a type: time dimension. Second: always set expr explicitly on every dimension. Third: measures are private — end users query metrics, not measures.


Step 3

Define your metrics

15 min · where business meaning lives
models/metrics/revenue_metrics.yml
metrics: - name: monthly_revenue label: Monthly Revenue description: > Total gross order revenue per month. Excludes cancelled orders and test accounts. type: simple type_params: measure: order_revenue filter: | {{ Dimension('sem_orders__order_status') }} != 'cancelled' meta: owner: finance-data@company.com reviewed_at: 2026-04-29

Filter dimension syntax: use sem_orders__order_status — two underscores between semantic model name and dimension name. Writing order_status alone causes a validation error.


The global namespace trap

dbt models are scoped — the semantic layer is not. Every entity, dimension, measure, and metric name is registered in a single global namespace across your entire project. Define order_revenue for e-commerce and later try to define it for wholesale — you get a parse error.

Rule: prefix everything with the domain name from day one. Use sem_ecommerce_orders not sem_orders. Use ecommerce_order_revenue not order_revenue. Document this convention in a README in your semantic models folder.


Step 4–5

Validate, then query

10 min · always in this order
Validation sequence
# 1. Catch YAML syntax errors — no warehouse needed dbt parse # 2. Validate semantic models and metrics dbt sl validate # dbt Cloud mf validate-configs # dbt Core # 3. List your metrics dbt sl list metrics # 4. Check dimensions for a specific metric dbt sl list dimensions --metrics monthly_revenue # 5. Run your first end-to-end query dbt sl query \ --metrics monthly_revenue \ --group-by metric_time__month \ --limit 12 # Use --explain to see the SQL MetricFlow generates dbt sl query --metrics monthly_revenue \ --group-by metric_time__month --explain

Step 6–7

Deploy and connect your BI tool

30–60 min depending on tool

In dbt Cloud: run dbt build in your production environment, then go to Account Settings → Semantic Layer. Generate a service token with Semantic Layer Only permissions.

Tableau (GA): Connect → To a Server → More → search dbt Semantic Layer. Enter host URL and environment ID.

Google Sheets / Excel: Install the dbt Semantic Layer add-on from the Workspace Marketplace.

Power BI (Preview): Download the .msi installer from dbt docs. Use DirectQuery mode. Power BI Service requires an IT-configured On-premises Data Gateway.


The seven pitfalls that will cost you a week

1
Skipping the time spine
Symptom: "dimension not found" on any time-based metric
Fix: create metricflow_time_spine.sql and run dbt build first
2
Semantic models on staging tables
Symptom: business logic has nowhere clean to live
Fix: semantic models must always sit above mart layer
3
Ignoring the global namespace
Symptom: parse errors when adding a second domain with reused names
Fix: prefix all semantic model and measure names with the domain from day one
4
Confusing measures with metrics
Symptom: analysts can't find the revenue metric — it was published as a measure
Fix: measures are private. Only metrics are queryable by downstream consumers
5
Omitting expr on dimensions
Symptom: MetricFlow silently uses the wrong column
Fix: always set expr explicitly — even when column name matches dimension name
6
Summing pre-calculated percentages
Symptom: aggregated percentage metrics produce values over 100%
Fix: store numerator and denominator separately; derive percentage at the metric layer
7
Running validate before dbt parse
Symptom: cryptic MetricFlow errors that don't point to the real problem
Fix: dbt parse always runs first — MetricFlow validation won't run if the project doesn't parse

What the first week actually looks like

Day 1Set up the time spine. Define your first semantic model on the orders mart. Validate locally. Do not touch the BI connection yet.
Day 2Define your first two metrics. Run end-to-end queries with --explain. Share the SQL output with Finance for sanity-check before going further.
Day 3Add naming prefixes for multiple domains. Define a second semantic model (customers). Build a ratio metric as your first cross-model metric.
Day 4Deploy to production. Connect one BI tool. Confirm metric queries return the same numbers as your CLI output.
Day 5Document. Every metric gets a description, an owner, a reviewed_at date, and a note on what it excludes. This is what separates a semantic layer that gets adopted from one that gets abandoned.

Do not try to migrate all your existing metrics at once. Pick two or three that matter most, define them with stakeholder sign-off, deploy, and prove value. The rest follows.

Next in the series — publishing May 6

Governance, security, and access control at semantic layer scale
#TheDataBrief#SemanticLayer#dbt#MetricFlow#DataEngineering#AnalyticsEngineering