Appendix E: dbt Reference
Pinned to dbt-core 1.9.1 with dbt-duckdb 1.9.1 (Appendix A).
E.1 Project Layout
dbt_project.yml
profiles.yml # NOT in the repository
models/
staging/ # silver: one model per source table
_sources.yml
_staging__models.yml
stg_orders.sql
intermediate/ # optional; not consumed directly
int_orders_joined.sql
marts/ # gold
core/
dim_customer.sql
fct_order_line.sql
finance/
daily_revenue.sql
seeds/ # small versioned CSVs
snapshots/ # SCD Type 2
macros/
tests/ # singular tests
analyses/
# dbt_project.yml
name: kestrel
version: "1.0.0"
profile: kestrel
model-paths: ["models"]
models:
kestrel:
staging:
+materialized: view
+schema: silver
+tags: ["silver"]
marts:
+materialized: table
+schema: gold
+tags: ["gold"]
finance:
+materialized: incremental
Configs cascade by directory, most specific wins, and a {{ config() }} block in the model wins over
all of them.
E.2 Materializations
| Writes | Rebuilds | Use for | |
|---|---|---|---|
view |
nothing | every query | staging, cheap transforms |
table |
full table | every run | small marts, anything queried often |
incremental |
new rows | only new rows | large facts |
ephemeral |
nothing | inlined as a CTE | small shared logic |
materialized_view |
a MV | engine-managed | engine-dependent |
{{ config(materialized='table', tags=['gold']) }}
Start with view for staging and table for marts. Go incremental when the full refresh becomes the
problem, and not before — incremental is where the correctness bugs live (ch20).
E.3 Incremental Models
{{ config(
materialized='incremental',
unique_key=['order_line_id'],
incremental_strategy='merge',
on_schema_change='append_new_columns',
partition_by={'field': 'event_date', 'data_type': 'date'}
) }}
SELECT *
FROM {{ ref('stg_order_lines') }}
{% if is_incremental() %}
-- the lookback is what catches late-arriving data (ch20)
WHERE event_date >= (SELECT max(event_date) FROM {{ this }})
- INTERVAL '3 days'
{% endif %}
Strategies:
| Strategy | How | Idempotent? |
|---|---|---|
append |
inserts | ✗ — a rerun duplicates |
delete+insert |
deletes matching keys, inserts | ✓ |
merge |
upserts on unique_key |
✓ |
insert_overwrite |
replaces whole partitions | ✓ — and the cleanest |
microbatch (1.9+) |
one bounded time window per batch | ✓ by construction |
append is the default on some adapters and it is not idempotent. State the strategy explicitly.
microbatch is the 1.9 addition worth knowing:
{{ config(
materialized='incremental',
incremental_strategy='microbatch',
event_time='placed_at',
batch_size='day',
lookback=3,
begin='2026-01-01'
) }}
SELECT * FROM {{ ref('stg_orders') }}
Backfills become parallel, and each batch is a separate idempotent write.
E.4 Sources, Freshness, and Exposures
# models/staging/_sources.yml
version: 2
sources:
- name: kestrel_raw
schema: bronze
loaded_at_field: _ingested_at
freshness:
warn_after: {count: 2, period: hour}
error_after: {count: 6, period: hour}
tables:
- name: orders_raw
columns:
- name: order_id
tests: [not_null]
- name: supplier_feed
freshness: null # this one is weekly; opt out
meta:
external: true
upstream_owner: "supplier-b, ops@supplier-b.example"
upstream_controls: "none known; no change notification"
# exposures: who breaks if this breaks (ch30)
exposures:
- name: executive_daily_revenue
type: dashboard
maturity: high
url: https://bi.example/dash/17
owner: {name: Finance, email: finance@example.com}
depends_on: [ref('daily_revenue')]
dbt source freshness before the build is the assertion that stops you transforming stale data.
E.5 Tests
# generic tests: four lines, most of the value
models:
- name: fct_order_line
description: "One row per order line, after refunds. Grain: order_line_id."
columns:
- name: order_line_id
tests: [unique, not_null]
- name: customer_sk
tests:
- not_null
- relationships:
to: ref('dim_customer')
field: customer_sk
- name: status
tests:
- accepted_values:
values: ['placed', 'shipped', 'refunded']
tests:
# the grain test (ch23): nine lines, catches every fan-out
- dbt_utils.unique_combination_of_columns:
combination_of_columns: [order_id, line_number]
-- tests/assert_revenue_reconciles.sql -- a singular test returns FAILING rows
SELECT g.revenue_date, g.revenue_cents, s.revenue_cents AS source_cents
FROM {{ ref('daily_revenue') }} g
JOIN {{ source('kestrel_raw', 'source_daily_totals') }} s USING (revenue_date)
WHERE g.revenue_cents <> s.revenue_cents
Severity and thresholds:
- name: refund_cents
tests:
- dbt_utils.accepted_range:
min_value: 0
config:
severity: warn # or error
error_if: ">100" # fail only above 100 failing rows
warn_if: ">0"
Unit tests (1.8+) — for the gnarly CASE expression, not for everything:
unit_tests:
- name: test_revenue_excludes_gift_cards
model: fct_order_line
given:
- input: ref('stg_order_lines')
rows:
- {order_id: 1, sku: 'KS-1', cents: 2500}
- {order_id: 1, sku: 'GC-10', cents: 5000}
expect:
rows:
- {order_id: 1, revenue_cents: 2500}
E.6 Jinja and Macros
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column, decimals=2) -%}
round(({{ column }} / 100.0)::numeric, {{ decimals }})
{%- endmacro %}
-- usage
SELECT {{ cents_to_dollars('net_revenue_cents') }} AS net_revenue FROM ...
The whitespace control (-%}, {%-) matters for readable compiled SQL, which you will read.
Useful built-ins:
{{ ref('stg_orders') }} -- builds the DAG. Never write a table name.
{{ source('kestrel_raw', 'orders_raw') }}
{{ this }} -- the current model's relation
{{ var('start_date', '2026-01-01') }} -- with a default
{{ env_var('DBT_WAREHOUSE') }} -- credentials come from here, never inline
{{ target.name }} -- dev / prod
{{ run_started_at }}
{% if execute %} -- guard anything querying during parse
{% if execute %} matters because dbt parses every model twice, and a run_query at parse time
fails or is slow.
Debugging: dbt compile --select my_model, then read target/compiled/.../my_model.sql. The
compiled SQL is the truth, and reading it resolves most Jinja confusion in a minute.
E.7 Node Selection
dbt build --select stg_orders # one model
dbt build --select stg_orders+ # and everything downstream
dbt build --select +fct_order_line # and everything upstream
dbt build --select +fct_order_line+ # both
dbt build --select tag:gold
dbt build --select path:models/marts/finance
dbt build --select stg_orders+ --exclude tag:slow
dbt build --select state:modified+ --state ./prod-manifest # slim CI
dbt build --select source_status:fresher+ --state ./prod-manifest
dbt build --select "@fct_order_line" # the model, its parents, and their children
state:modified+ with a stored production manifest is what makes CI fast (ch19, ch27) — build what
changed and what depends on it, not the project.
E.8 Snapshots
{% snapshot dim_customer_snapshot %}
{{ config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at',
invalidate_hard_deletes=True
) }}
SELECT * FROM {{ source('kestrel_raw', 'customers') }}
{% endsnapshot %}
This is SCD Type 2, and it is a point-in-time-correct feature table (ch32) — a fact most dbt users have not connected.
strategy='check' with check_cols when the source has no reliable updated_at, which per Chapter
13 is often.
invalidate_hard_deletes=True is off by default and is usually what you want.
E.9 Commands
dbt deps # install packages
dbt debug # connection and profile check -- run this first
dbt seed
dbt run
dbt test
dbt build # run + test + seed + snapshot, in DAG order
dbt snapshot
dbt source freshness
dbt docs generate && dbt docs serve
dbt clean
dbt ls --select tag:gold --resource-type model
dbt run-operation my_macro --args '{"day": "2026-11-14"}'
dbt parse # produces manifest.json without running anything
Prefer dbt build to dbt run + dbt test. It interleaves them in DAG order, so a failing test
stops the models downstream of it rather than after everything has been built.
E.10 Practices Worth Adopting
One staging model per source table, doing only renaming, casting, and deduplication (§34.3's silver rule).
ref() always. A hardcoded table name is a model outside the DAG, and Chapter 38 Case Study 2 shows
what that does to a rebuild.
Document the grain in description. It is §30.2's most valuable field, and dbt puts it in the docs
for free.
Store the production manifest.json as a CI artifact. Everything in §E.7 depends on it.
Credentials from env_var() only. validate.py fails the build on a literal.
And test the sources, not only the models. A not_null on a source column fails where the problem
is, rather than three models later.