Building dbt Models
When to use
- Creating or refactoring
.sql models in a dbt project.
- Deciding materialization (view / table / incremental / ephemeral).
- Structuring layers (staging → intermediate → marts).
- Writing incremental models for large, growing tables.
- Do NOT use for test authoring (use
testing-dbt-projects) or run failures
(use debugging-dbt-runs).
Workflow
- [ ] Place the model in the right layer (staging/intermediate/marts)
- [ ] Reference upstream only via ref()/source() — never hard-coded names
- [ ] Choose materialization by size and refresh needs
- [ ] For incremental, set unique_key + is_incremental() filter
- [ ] Add a schema.yml entry with tests
- Layer it.
staging/ = one model per source table, light renaming/typing,
materialized as views. intermediate/ = reusable business logic. marts/ =
final dimensional models consumed by BI, materialized as tables.
- Reference correctly. Use
{{ ref('stg_orders') }} and
{{ source('shop', 'orders') }} so dbt builds the DAG and manages
environments. Never write raw schema.table.
- Pick materialization: view (cheap, always fresh, small), table (fast reads,
rebuilt each run), incremental (large append/update tables), ephemeral (inlined
CTE, no object).
- Incremental models process only new/changed rows.
Patterns
Staging model — one per source, thin and consistent:
-- models/staging/shop/stg_orders.sql
with source as (select * from {{ source('shop', 'orders') }})
select
order_id,
customer_id,
cast(order_ts as timestamp) as ordered_at,
round(amount_cents / 100.0, 2) as amount
from source
Incremental model — filter to new rows and set an idempotent merge key:
{{ config(materialized='incremental', unique_key='order_id',
incremental_strategy='merge') }}
select * from {{ ref('stg_orders') }}
{% if is_incremental() %}
-- only rows newer than what we already loaded, with a lookback for late data
where ordered_at >= (select coalesce(max(ordered_at), '1900-01-01') from {{ this }})
- interval '3 days'
{% endif %}
The unique_key + merge makes re-runs idempotent; the lookback catches
late-arriving rows. On BigQuery/Spark, prefer insert_overwrite on a date
partition.
Common pitfalls
- Hard-coded table names instead of
ref()/source() — breaks the DAG,
lineage, and environment switching.
- Incremental without
unique_key — re-runs append duplicates.
max(id) incremental filter with no lookback — silently drops late data.
- Business logic in staging — keep staging thin; joins/aggregation belong in
intermediate/marts.
- Everything materialized as
table — wastes warehouse time; use views for
small/cheap models and incremental for large ones.
- One giant model — split into intermediate steps for testability and reuse.
References
- dbt project structure conventions
1---2name: building-dbt-models3description: Build well-structured dbt models — staging/intermediate/marts layers, ref() and source(), materializations, and incremental models with the right strategy. Use when creating or refactoring dbt models, choosing table vs view vs incremental, structuring a dbt project, or writing incremental logic.4---56# Building dbt Models78## When to use910- Creating or refactoring `.sql` models in a dbt project.11- Deciding materialization (view / table / incremental / ephemeral).12- Structuring layers (staging → intermediate → marts).13- Writing incremental models for large, growing tables.14- Do NOT use for test authoring (use `testing-dbt-projects`) or run failures15 (use `debugging-dbt-runs`).1617## Workflow1819```20- [ ] Place the model in the right layer (staging/intermediate/marts)21- [ ] Reference upstream only via ref()/source() — never hard-coded names22- [ ] Choose materialization by size and refresh needs23- [ ] For incremental, set unique_key + is_incremental() filter24- [ ] Add a schema.yml entry with tests25```26271. **Layer it.** `staging/` = one model per source table, light renaming/typing,28 materialized as views. `intermediate/` = reusable business logic. `marts/` =29 final dimensional models consumed by BI, materialized as tables.302. **Reference correctly.** Use `{{ ref('stg_orders') }}` and31 `{{ source('shop', 'orders') }}` so dbt builds the DAG and manages32 environments. Never write raw schema.table.333. **Pick materialization:** view (cheap, always fresh, small), table (fast reads,34 rebuilt each run), incremental (large append/update tables), ephemeral (inlined35 CTE, no object).364. **Incremental models** process only new/changed rows.3738## Patterns3940**Staging model** — one per source, thin and consistent:4142```sql43-- models/staging/shop/stg_orders.sql44with source as (select * from {{ source('shop', 'orders') }})45select46 order_id,47 customer_id,48 cast(order_ts as timestamp) as ordered_at,49 round(amount_cents / 100.0, 2) as amount50from source51```5253**Incremental model** — filter to new rows and set an idempotent merge key:5455```sql56{{ config(materialized='incremental', unique_key='order_id',57 incremental_strategy='merge') }}5859select * from {{ ref('stg_orders') }}60{% if is_incremental() %}61 -- only rows newer than what we already loaded, with a lookback for late data62 where ordered_at >= (select coalesce(max(ordered_at), '1900-01-01') from {{ this }})63 - interval '3 days'64{% endif %}65```6667The `unique_key` + `merge` makes re-runs idempotent; the lookback catches68late-arriving rows. On BigQuery/Spark, prefer `insert_overwrite` on a date69partition.7071## Common pitfalls7273- **Hard-coded table names** instead of `ref()`/`source()` — breaks the DAG,74 lineage, and environment switching.75- **Incremental without `unique_key`** — re-runs append duplicates.76- **`max(id)` incremental filter with no lookback** — silently drops late data.77- **Business logic in staging** — keep staging thin; joins/aggregation belong in78 intermediate/marts.79- **Everything materialized as `table`** — wastes warehouse time; use views for80 small/cheap models and incremental for large ones.81- **One giant model** — split into intermediate steps for testability and reuse.8283## References8485- [dbt project structure conventions](references/STRUCTURE.md)