Hands-On Project: Build a dbt Project¶
Build a tested star schema with dbt: staging models over raw data, a Type 2 customer dimension from a snapshot, an incremental fact table with an enforced contract, generic, singular and unit tests, docs with lineage, and a CI command that only builds what changed.
Estimated Time: 4-5 hours Difficulty: Intermediate Prerequisites: SQL, Python 3.10+, git. Read Analytics engineering and dbt. The design thinking behind the model is in Design a star schema. Cost: Free. Runs locally on DuckDB; the last step shows how to point it at a cloud warehouse. Tested: 2026-10-21 with dbt Core 1.12, dbt-duckdb and dbt_utils 1.4 (dbt build: 28 passed). dbt v2 is the default for new installs; see Analytics engineering and dbt before mixing the two.
What you will build¶
flowchart LR
subgraph Seeds["Raw data (seeds)"]
RC[raw_customers]
RP[raw_products]
RO[raw_orders]
RL[raw_order_lines]
end
RC --> SNAP[customers_snapshot<br/>SCD Type 2]
RC --> SC[stg_customers]
RP --> SP[stg_products]
RO --> SO[stg_orders]
RL --> SL[stg_order_lines]
SNAP --> DC[dim_customers]
SP --> DP[dim_products]
DATE[dim_date]
SO --> FS[fct_sales<br/>incremental, contract]
SL --> FS
DC --> FS
DP --> FS
DATE --> FS
FS --> RPT[rpt_monthly_revenue] The project's DAG: raw seeds, a staging model per source, a snapshot for customer history, conformed dimensions, one incremental fact, and a reporting model on top. dbt derives this graph from the ref calls in the SQL.
Step 1: Install and initialize¶
mkdir dbt-lab && cd dbt-lab
python3 -m venv .venv && source .venv/bin/activate
pip install "dbt-core>=1.10,<2" dbt-duckdb
dbt --version # this lab was tested with dbt Core 1.12 and dbt-duckdb
dbt init shop # choose duckdb when asked
cd shop
git init
Edit ~/.dbt/profiles.yml (dbt created it) so the profile points at a local file:
shop:
target: dev
outputs:
dev:
type: duckdb
path: shop.duckdb
threads: 4
Delete the example models dbt generated (rm -r models/example) and remove the example: block under models: in dbt_project.yml. Replace that block with layer defaults:
models:
shop:
staging:
+materialized: view
marts:
+materialized: table
Add the dbt_utils package in packages.yml:
packages:
- package: dbt-labs/dbt_utils
version: [">=1.3.0", "<2.0.0"]
dbt deps
dbt debug # "All checks passed!"
Step 2: Generate raw data as seeds¶
Seeds are CSV files dbt loads as tables. Real projects get raw data from a loader (Fivetran, Airbyte, a pipeline); seeds keep this lab self-contained.
# make_seeds.py - run from the shop/ folder
import csv, random
from datetime import date, timedelta
random.seed(7)
def write(name, header, rows):
with open(f"seeds/{name}.csv", "w", newline="") as f:
w = csv.writer(f); w.writerow(header); w.writerows(rows)
segments, countries = ["hobbyist", "commuter", "pro"], ["GB", "DE", "FR", "NL"]
write("raw_customers", ["customer_id", "name", "email", "country", "segment"],
[[f"C-{i}", f"Customer {i}", f"c{i}@example.com",
random.choice(countries), random.choice(segments)] for i in range(1, 201)])
cats = ["drivetrain", "brakes", "wheels", "cockpit"]
write("raw_products", ["product_id", "name", "category", "list_price"],
[[f"P-{i}", f"Part {i}", random.choice(cats), round(random.uniform(5, 300), 2)]
for i in range(1, 41)])
orders, lines, start = [], [], date(2026, 1, 1)
for o in range(1, 3001):
d = start + timedelta(days=random.randint(0, 290))
status = random.choices(["complete", "cancelled", "returned"], [90, 7, 3])[0]
orders.append([f"O-{o}", f"C-{random.randint(1, 200)}", d.isoformat(), status,
round(random.choice([0, 0, 4.99, 9.99]), 2)])
for n in range(1, random.randint(1, 4) + 1):
lines.append([f"O-{o}", n, f"P-{random.randint(1, 40)}", random.randint(1, 3),
round(random.uniform(5, 300), 2)])
write("raw_orders", ["order_id", "customer_id", "order_date", "status", "shipping_fee"], orders)
write("raw_order_lines", ["order_id", "line_no", "product_id", "quantity", "unit_price"], lines)
python make_seeds.py
dbt seed
Checkpoint: dbt seed reports four tables loaded.
Step 3: Staging models¶
One model per raw table, in models/staging/. Rename, cast, nothing else.
-- models/staging/stg_orders.sql
select
order_id,
customer_id,
cast(order_date as date) as order_date,
lower(status) as status,
cast(shipping_fee as decimal(10,2)) as shipping_fee
from {{ ref('raw_orders') }}
-- models/staging/stg_order_lines.sql
select
order_id,
cast(line_no as integer) as line_no,
product_id,
cast(quantity as integer) as quantity,
cast(unit_price as decimal(10,2)) as unit_price,
cast(quantity * unit_price as decimal(12,2)) as line_revenue
from {{ ref('raw_order_lines') }}
-- models/staging/stg_products.sql
select product_id, name as product_name, category,
cast(list_price as decimal(10,2)) as list_price
from {{ ref('raw_products') }}
Write stg_customers.sql the same way (select and rename the five columns).
In a real project the raw tables would be declared as sources in YAML and referenced with {{ source('shop', 'raw_orders') }}. Seeds are referenced with ref, which is why this lab uses ref throughout.
dbt run --select staging
Step 4: Customer history with a snapshot¶
A snapshot compares the source to what it saw last time and keeps Type 2 history. Create snapshots/customers_snapshot.yml:
snapshots:
- name: customers_snapshot
relation: ref('raw_customers')
config:
unique_key: customer_id
strategy: check
check_cols: [country, segment]
strategy: check creates a new version when any listed column changes. (The alternative, timestamp, uses an updated_at column, and is preferred when the source has a reliable one.)
dbt snapshot
Now simulate a change. Edit seeds/raw_customers.csv, change C-1's country to something else and its segment to pro, then:
dbt seed --select raw_customers
dbt snapshot
Query it (duckdb shop.duckdb, or dbt show):
dbt show --inline "select customer_id, country, segment, dbt_valid_from, dbt_valid_to
from {{ ref('customers_snapshot') }} where customer_id = 'C-1'"
Checkpoint: two rows for C-1. The old one has a dbt_valid_to; the new one has none.
In production a snapshot runs on a schedule, and it only sees changes between runs. See Slowly changing dimensions for why CDC is better when intermediate states matter.
Step 5: Dimensions¶
-- models/marts/dim_customers.sql
select
{{ dbt_utils.generate_surrogate_key(['customer_id', 'dbt_valid_from']) }} as customer_key,
customer_id,
name,
email,
country,
segment,
-- the first version a snapshot ever sees is treated as valid from the start of time
case when dbt_valid_from = min(dbt_valid_from) over (partition by customer_id)
then date '1900-01-01'
else cast(dbt_valid_from as date) end as valid_from,
coalesce(cast(dbt_valid_to as date), date '9999-12-31') as valid_to,
dbt_valid_to is null as is_current
from {{ ref('customers_snapshot') }}
-- models/marts/dim_products.sql
select
{{ dbt_utils.generate_surrogate_key(['product_id']) }} as product_key,
product_id, product_name, category, list_price
from {{ ref('stg_products') }}
-- models/marts/dim_date.sql
select
cast(strftime(d, '%Y%m%d') as integer) as date_key,
cast(d as date) as date_day,
year(d) as year,
month(d) as month,
strftime(d, '%Y-%m') as year_month,
dayofweek(d) in (0, 6) as is_weekend
from generate_series(date '2025-01-01', date '2027-12-31', interval 1 day) as t(d)
Why the case on valid_from: the snapshot started today, so without it every customer's first version would begin today, after all the 2026 orders, and the fact table's range join would find no customer. Backdating the first version a snapshot ever saw is the usual fix on first load. Versions created by later snapshot runs keep their real start date.
Step 6: An incremental fact with a contract¶
The grain is one row per order line. Order-level shipping is allocated across lines by revenue, so it sums back exactly (see Design a star schema step 4). Cancelled orders are excluded.
-- models/marts/fct_sales.sql
{{ config(
materialized='incremental',
unique_key=['order_id', 'line_no'],
on_schema_change='fail'
) }}
with lines as (
select l.*, o.customer_id, o.order_date, o.shipping_fee,
sum(l.line_revenue) over (partition by l.order_id) as order_revenue
from {{ ref('stg_order_lines') }} l
join {{ ref('stg_orders') }} o using (order_id)
where o.status <> 'cancelled'
{% if is_incremental() %}
-- reprocess a 3-day lookback so late-arriving lines are not skipped
and o.order_date >= (select max(order_date) from {{ this }}) - interval 3 day
{% endif %}
)
select
lines.order_id,
lines.line_no,
lines.order_date,
cast(strftime(lines.order_date, '%Y%m%d') as integer) as date_key,
c.customer_key,
p.product_key,
lines.quantity,
lines.line_revenue as revenue,
cast(lines.shipping_fee * lines.line_revenue
/ nullif(lines.order_revenue, 0) as decimal(12,2)) as allocated_shipping
from lines
join {{ ref('dim_customers') }} c
on c.customer_id = lines.customer_id
and lines.order_date >= c.valid_from and lines.order_date < c.valid_to
join {{ ref('dim_products') }} p on p.product_id = lines.product_id
Two things to notice: the customer join is a range join that picks the version valid on the order date, and the incremental filter has a lookback so a late line for a three-day-old order is still picked up and merged by unique_key.
Now declare and enforce the model's shape in models/marts/_marts.yml:
models:
- name: fct_sales
description: One row per order line of a non-cancelled order.
config:
contract:
enforced: true
columns:
- name: order_id
data_type: varchar
constraints: [{ type: not_null }]
- name: line_no
data_type: integer
- name: order_date
data_type: date
- name: date_key
data_type: integer
data_tests:
- relationships: { to: ref('dim_date'), field: date_key }
- name: customer_key
data_type: varchar
data_tests:
- not_null
- relationships: { to: ref('dim_customers'), field: customer_key }
- name: product_key
data_type: varchar
data_tests:
- relationships: { to: ref('dim_products'), field: product_key }
- name: quantity
data_type: integer
- name: revenue
data_type: decimal(12,2)
data_tests:
- dbt_utils.accepted_range: { min_value: 0 }
- name: allocated_shipping
data_type: decimal(12,2)
data_tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns: [order_id, line_no]
- name: dim_customers
columns:
- name: customer_key
data_tests: [unique, not_null]
- name: segment
data_tests:
- accepted_values: { values: [hobbyist, commuter, pro] }
- name: dim_products
columns:
- name: product_key
data_tests: [unique, not_null]
With the contract enforced, if someone changes the SQL so a column disappears or changes type, the build fails before the table is replaced. See Data contracts.
Step 7: Singular and unit tests¶
A singular test for a business rule: revenue in the fact must match the raw lines of non-cancelled orders.
-- tests/assert_fct_sales_reconciles_to_source.sql
with fact as (select sum(revenue) as r from {{ ref('fct_sales') }}),
src as (
select sum(l.quantity * l.unit_price) as r
from {{ ref('raw_order_lines') }} l
join {{ ref('raw_orders') }} o using (order_id)
where lower(o.status) <> 'cancelled'
)
select fact.r as fact_revenue, src.r as source_revenue
from fact, src
where abs(fact.r - src.r) > 0.01
And one for the allocation rule: per order, allocated shipping must add back up to the fee.
-- tests/assert_shipping_allocates_exactly.sql
select f.order_id
from {{ ref('fct_sales') }} f
join {{ ref('stg_orders') }} o using (order_id)
group by f.order_id, o.shipping_fee
having abs(sum(f.allocated_shipping) - o.shipping_fee) > 0.02
A unit test checks logic on fixed inputs, without touching real data. Add to _marts.yml:
unit_tests:
- name: shipping_is_split_by_revenue
model: fct_sales
overrides:
macros:
is_incremental: false
given:
- input: ref('stg_orders')
rows:
- { order_id: O-9, customer_id: C-9, order_date: 2026-05-01, status: complete, shipping_fee: 10.00 }
- input: ref('stg_order_lines')
rows:
- { order_id: O-9, line_no: 1, product_id: P-1, quantity: 1, unit_price: 30.00, line_revenue: 30.00 }
- { order_id: O-9, line_no: 2, product_id: P-2, quantity: 1, unit_price: 10.00, line_revenue: 10.00 }
- input: ref('dim_customers')
rows:
- { customer_key: k9, customer_id: C-9, valid_from: 1900-01-01, valid_to: 9999-12-31 }
- input: ref('dim_products')
rows:
- { product_key: p1, product_id: P-1 }
- { product_key: p2, product_id: P-2 }
expect:
rows:
- { order_id: O-9, line_no: 1, allocated_shipping: 7.50 }
- { order_id: O-9, line_no: 2, allocated_shipping: 2.50 }
Step 8: Build everything¶
dbt build
dbt build runs seeds, snapshots, models and tests in DAG order, and skips anything downstream of a failed test. Expect everything to pass. Then break something on purpose to see the safety net work:
- In
stg_order_lines.sql, changecast(line_no as integer)to1 as line_no. dbt build- theunique_combination_of_columnstest onfct_salesfails, andrpt_monthly_revenue(next step) is skipped rather than built on bad data.- Revert.
Step 9: A reporting model and docs¶
-- models/marts/rpt_monthly_revenue.sql
select d.year_month, p.category, c.segment,
count(distinct f.order_id) as orders,
sum(f.revenue) as revenue
from {{ ref('fct_sales') }} f
join {{ ref('dim_date') }} d using (date_key)
join {{ ref('dim_products') }} p using (product_key)
join {{ ref('dim_customers') }} c using (customer_key)
group by all
Add description: lines to the models and key columns in _marts.yml, then:
dbt docs generate
dbt docs serve # opens a site with every model, column, test and the lineage graph
Step 10: Build only what changed (CI)¶
In CI you do not rebuild the whole project for every pull request. dbt compares the current code to the manifest from production and selects what changed plus everything downstream.
# Save the "production" state once
dbt build && mkdir -p prod-state && cp target/manifest.json prod-state/
# Change one model, then build only the changed models and their children
dbt build --select state:modified+ --state prod-state
In a real pipeline the production manifest is downloaded from storage, the build runs in a disposable CI schema, and the pull request cannot merge until it passes.
Step 11 (optional): Point it at a cloud warehouse¶
Install the adapter for your platform (dbt-snowflake, dbt-bigquery, dbt-databricks, dbt-redshift, dbt-fabric), add a second output to profiles.yml, and run with --target. Two things will need attention:
- SQL dialect.
strftime,generate_seriesandgroup by allare DuckDB spellings. Usedbt_utils.date_spineand cross-database macros, or adjust per platform. - Seeds are for small reference data. On a warehouse, load the raw tables with a real loader and declare them as
sourceswith freshness checks.
Deliverables¶
- A git repository with the project, a README stating the grain of
fct_salesand the shipping allocation rule. - A screenshot of the lineage graph from
dbt docs serve. - Evidence that a deliberate break was caught by a test and blocked downstream models.
Extensions¶
- Add source freshness checks (
loaded_at_field,warn_after,error_after) once you load data from a real source. - Add a semantic model and metrics for revenue, so every BI tool shares one definition.
- Orchestrate
dbt buildafter the loader finishes, with Airflow, Dagster or the dbt platform scheduler. See Data pipelines and orchestration. - Run the same project with SQLMesh or Dataform and compare.
Related¶
- Analytics engineering and dbt - the concepts behind every step
- Design a star schema - the design method before the code
- Data quality and lineage - where tests fit in the bigger picture
- Fabric Analytics Engineer (DP-600) - an analytics engineering certification
Official docs¶
- π dbt: Snapshots - YAML snapshot configuration and strategies
- π dbt: Unit tests - fixed-input tests for model logic
- π dbt: Model contracts - enforced column names, types and constraints
- π dbt-duckdb adapter - the adapter used for the local build