S051 · dbt — Models, Tests, Docs, Warehouse-Native ELT
The tool that turned the data team into a software team. Learn dbt's core primitives — models, sources, tests, macros — and why ‘transform in the warehouse’ won the last decade.
🎯 Structure a dbt project with staging → intermediate → mart layers, add tests + documentation, and defend ELT (‘transform in the warehouse’) over ETL (‘transform in Spark first’).
Why this session exists
Before dbt, data teams shipped fragile SQL in Airflow DAGs, cron scripts, and stored procedures. Nothing was version-controlled, nothing was tested, and lineage lived in a wiki. dbt didn't invent SQL — it just added the software-engineering primitives (version control, tests, docs, dependency graph) around it. In eight years it became the industry standard for warehouse transformations, killing 30 years of ETL-tool market share. Learning it is table-stakes for any modern data role.
- Explain the ELT vs ETL shift and why cloud warehouses made it possible.
- Structure a dbt project into staging → intermediate → mart layers.
- Write four types of tests (unique, not_null, accepted_values, relationships) + a custom singular test.
- Understand macros, ref(), source(), and why they're the whole ballgame.
- Choose between the four materialisations (view, table, incremental, ephemeral).
Prerequisites
- S040 — Joins & Set Operations (dbt is 90 % SQL).
- S045 — Data modelling (dbt is Kimball with a build system).
- S050 — Airflow (dbt runs from an orchestrator).
(a) Intuition · 5 min
Imagine your entire warehouse's business logic is a giant pile of loose SQL scripts. Each analyst has three of their own. Nobody knows which query is the ‘official’ revenue definition. When the CFO asks ‘what's Q3 revenue?’ you get four different numbers. This is what data teams looked like in 2015.
Now imagine every SQL query lives in a git repo, has tests, has documentation, has an explicit dependency graph (‘this query depends on those three tables’), and gets built by a compiler that knows how to run them in order. That's dbt. It didn't invent SQL, it invented engineering discipline around SQL.
A dbt model is a SELECT statement in a .sql file. dbt wraps it with CREATE TABLE / CREATE VIEW at build time. The magic function `{{ ref('other_model') }}` declares a dependency; dbt builds a DAG and runs in order. Tests are also SQL — a query that should return zero rows to pass. Sources are external tables (from Fivetran, Airbyte, custom loaders); everything downstream refs them.
Compilation matters: `dbt compile` converts your Jinja SQL into the raw warehouse SQL. `dbt run` then executes it. Combined with tests + docs + git, you get software engineering for analytics.
The four ideas that unlock everything
- Model = a SELECT statement. dbt materialises it as view / table / incremental / ephemeral.
- ref() and source() = declarative dependencies. dbt builds the DAG from them.
- Test = a SELECT that must return 0 rows to pass. Runs at build time as a gate.
- Macro = Jinja-powered reusable SQL snippet. dbt-utils and dbt-expectations extend the language.
A quick history so you know why the world looks like this
- 1990sETL rules — Informatica, DataStageTransform outside the warehouse; load pre-modelled tables. Slow to iterate, high license fees.
- 2012BigQuery + Redshift make warehouses cheapNow transforming inside the warehouse is economically viable.
- 2016dbt open-sourced by Fishtown AnalyticsTristan Handy et al. build the ELT tool. Grows via analytics-engineer word of mouth.
- 2019dbt Labs founded, $1B valuation‘Analytics engineer’ becomes an official job title.
- 2021Fivetran + dbt + Snowflake = ‘Modern Data Stack’The default architecture for every new startup.
- 2024dbt Mesh, dbt Cloud IDE, semantic layerMulti-project, semantic-layer, and metrics all first-class in the ecosystem.
(b) Visual walkthrough · 15 min
The staging → intermediate → mart layer cake
Materialisations — what actually happens at build time
CREATE VIEW
- No storage cost
- Recomputes on every SELECT
- Great for staging, cheap sources
- Bad for expensive transforms
CREATE TABLE AS SELECT
- Full recompute on each dbt run
- Storage cost = table size
- Great for marts under a few GB
- Bad for very large tables (long build)
MERGE / INSERT WHERE date > last
- Only processes new rows
- Requires unique_key + is_incremental() macro
- Great for event tables, large facts
- Requires care on backfills / schema changes
Inlined as CTE in downstream models
- No object in warehouse
- No storage, no DDL
- Great for logic reuse (helper transforms)
- Not queryable directly
Test types you'll actually use
dbt tests, in order of frequency
"dbt is a transformation engine. It runs my SQL, manages the compute, and orchestrates my pipeline."
dbt compiles Jinja+SQL into plain SQL and sends it to the warehouse. It executes nothing. All compute, cost and performance belong to Snowflake/BigQuery/Databricks/Synapse. dbt is a compiler plus a DAG walker plus a test runner — that is the entire product.
The myth is sticky because dbt run feels like execution: it prints a model-by-model progress log with per-model timings, so your brain assigns the work to dbt. It is also true in a narrow sense that dbt decides ordering and generates the materialization DDL. But when a model is slow, tuning dbt does nothing — you must read the compiled SQL and the warehouse query profile.
Compile without running and read what actually gets sent:
dbt compile --select my_model
cat target/compiled/PROJECT/models/my_model.sql
# and the DDL wrapper dbt generates:
dbt run --select my_model
cat target/run/PROJECT/models/my_model.sqlThe run/ version is just a create or replace table ... as (...) wrapper. That string is 100% of what dbt contributes at execution time.
Why did the industry flip from ETL to ELT? "Load the raw junk first, transform later" sounds strictly worse. It isn't arbitrary — it is forced.
- 1ETL exists because the warehouse used to be the scarcest resource. Teradata/Oracle boxes were fixed-size, capital-purchased, and shared by the whole company.forced by · you cannot buy ten more minutes of a machine you already own; the box is the bottleneck all day, every day
- 2So transformation was pushed off the warehouse into Informatica/SSIS/hand-rolled jobs, and only final clean tables were loaded.forced by · scarce query capacity must be reserved for users, not spent on cleaning
- 3Cloud warehouses decoupled storage from compute and made compute elastic and billed per second.forced by · separating the two lets you scale to 5x for four minutes and back down, so capacity stops being a fixed asset
- 4Once compute is elastic, transforming inside the warehouse costs the same CPU-seconds as transforming outside it — but the data never leaves, so you delete a network hop, a serialization format, and an entire second execution engine to operate.forced by · at scale, data movement and system count dominate both cost and failure rate, not raw computation
- 5Meanwhile object storage became cheap enough that keeping the untransformed source forever is cheaper than the engineering cost of re-extracting it after a logic bug.forced by · storage is priced per GB-month; a re-extract from a production OLTP system is a political negotiation with an owning team
Therefore ELT is not a fashion; it is the mechanical consequence of elastic compute plus cheap storage. The transform simply moved to where the data already sat, and dbt is the tool that appeared to manage transformations expressed as SQL in that place.
And note what this predicts: wherever compute is not elastic — a fixed on-prem cluster, a reserved-capacity pool already at full utilisation, a hard monthly credit cap — ETL's original argument returns intact, and you should expect to see pre-aggregation pushed back outside the warehouse. Go look at any cost-capped enterprise estate and you will find exactly that. The architecture follows the pricing model.
Every model is a target. {{ ref('x') }} is a declared dependency edge. dbt reads all the refs, builds a DAG, topologically sorts it, and runs targets in parallel waves — exactly like make -j. The "recipe" for each target is a CREATE TABLE AS SELECT handed to a database dbt does not own.
Tests are assertions attached to nodes in that same DAG, so they execute in dependency order too. Nothing else is happening.
- Never hardcode a table name.
ref()is the only thing that creates an edge — a raw string reference makes the DAG silently wrong and the run order silently unsafe. - Materialization is a per-node storage strategy, not a logic change: view (cheap to build, pay on every read), table (pay on build, cheap to read), incremental (pay only for new rows, pay in correctness complexity).
- Layer the DAG: staging (1:1 with source, rename and cast only) → intermediate (joins, business logic) → marts (what consumers touch). A model that skips a layer is technical debt that already has a name.
- Selectors are graph operators, not globs:
--select state:modified+means "changed nodes and everything downstream". That single operator is what makes CI on a 2000-model project finish in minutes instead of hours.
Fire this model the moment you see: a model that ran before its dependency · someone asking "why is dbt slow" (it isn't — your warehouse is) · a CI job rebuilding 800 models for a one-line change · an incremental model producing duplicates · a circular dependency error you can't explain.
A fact table grows by tens of millions of rows a day. Materialize it as table (full rebuild every run) or incremental?
is_incremental() filter is a hand-written assumption about which source rows can still change; events arriving outside your window are dropped silently and permanently, and any logic change requires a --full-refresh that a human must remember to run.merge on a unique key: cost stays bounded while late arrivals and corrections inside the window self-heal without intervention.merge is materially more expensive per row than append-only insert, and it demands a genuinely unique key. A key that is unique 99.99% of the time produces silent duplicates or silent overwrites — the worst class of data bug because nothing errors.unique test on the merge key has run in production and never failedStart every model as table. Convert to incremental only when a measured cost or runtime number forces it, and when you convert, write the lookback window as a variable with a comment stating the lateness assumption it encodes. The most expensive dbt bugs are not slow models — they are incremental models that quietly stopped including a slice of data eight months ago, found only when someone reconciles against the source.
Two disciplines make incrementals survivable: schedule a periodic --full-refresh (weekly or monthly) so drift can never accumulate unboundedly, and always pair the model with unique and not_null tests on its key. That full refresh is not redundant compute — it is the audit that proves your incremental logic is still correct.
(c) Hands-on · 25 min
Minimal end-to-end dbt project against local Postgres. Assume dbt-postgres is installed and a Postgres is running with a schema raw.
File tree:
dbt_project.yml:
name: my_dbt
version: 1.0.0
profile: my_dbt
model-paths: ["models"]
test-paths: ["tests"]
target-path: "target"
clean-targets: ["target", "dbt_packages"]
models:
my_dbt:
staging:
+materialized: view
marts:
+materialized: table~/.dbt/profiles.yml:
my_dbt:
target: dev
outputs:
dev:
type: postgres
host: localhost
user: postgres
password: demo
port: 5432
dbname: warehouse
schema: analytics
threads: 4models/sources.yml:
version: 2
sources:
- name: raw
database: warehouse
schema: raw
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour}
loaded_at_field: _loaded_at
tables:
- name: orders
columns:
- name: order_id
tests: [unique, not_null]
- name: customer_id
tests: [not_null]
- name: customers
columns:
- name: customer_id
tests: [unique, not_null]models/staging/stg_orders.sql:
with src as (
select * from {{ source('raw', 'orders') }}
)
select
order_id::bigint as order_id,
customer_id::bigint as customer_id,
amount::numeric(10,2) as amount,
status::text as status,
order_date::timestamp as order_ts
from src
where order_id is not nullmodels/staging/stg_customers.sql:
with src as (
select * from {{ source('raw', 'customers') }}
)
select
customer_id::bigint as customer_id,
lower(email) as email,
trim(city) as city,
signup_date::date as signup_date
from srcmodels/staging/_staging.yml:
version: 2
models:
- name: stg_orders
columns:
- name: order_id
tests: [unique, not_null]
- name: customer_id
tests: [not_null]
- name: status
tests:
- accepted_values:
values: ['pending', 'paid', 'refunded', 'cancelled']
- name: stg_customers
columns:
- name: customer_id
tests: [unique, not_null]models/marts/dim_customers.sql:
select
c.customer_id,
c.email,
c.city,
c.signup_date,
count(o.order_id) as lifetime_orders,
coalesce(sum(o.amount), 0) as lifetime_revenue
from {{ ref('stg_customers') }} c
left join {{ ref('stg_orders') }} o
on o.customer_id = c.customer_id
and o.status = 'paid'
group by 1,2,3,4models/marts/fct_orders.sql:
{{
config(
materialized='incremental',
unique_key='order_id',
on_schema_change='sync_all_columns'
)
}}
select
o.order_id,
o.customer_id,
o.amount,
o.status,
o.order_ts,
c.city as customer_city
from {{ ref('stg_orders') }} o
left join {{ ref('stg_customers') }} c using (customer_id)
{% if is_incremental() %}
where o.order_ts > (select coalesce(max(order_ts), '1970-01-01') from {{ this }})
{% endif %}models/marts/_marts.yml:
version: 2
models:
- name: fct_orders
description: One row per order. Incremental by order_ts.
columns:
- name: order_id
tests: [unique, not_null]
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: dim_customers
description: One row per customer with lifetime aggregates.
columns:
- name: customer_id
tests: [unique, not_null]tests/assert_positive_amount.sql:
-- Fails if any paid order has a non-positive amount.
select order_id, amount
from {{ ref('fct_orders') }}
where status = 'paid' and amount <= 0What each block does
Anatomy of the project
Insert a bad row directly into raw.orders:
INSERT INTO raw.orders (order_id, customer_id, amount, status, order_date, _loaded_at)
VALUES (1, 1, -10, 'paid', now(), now());Run dbt build --select fct_orders+. Watch:
stg_ordersunique test passes (the source stage tolerates a paid negative row because we only test the presence of a value).fct_ordersunique test fails on the duplicate order_id.assert_positive_amountalso fails on the negative amount.dim_customersand any downstream model marked as depending onfct_ordersis not built (unless--warn-erroroff).
This is the ELT circuit-breaker in action.
(d) Production reality · 15 min
Early dbt users would gleefully add hundreds of models with no folder discipline. Two years later, no one knew which model was the ‘official’ revenue definition, models were referenced from other projects, and a single change could cascade into 50 dashboards.
Adopted the staging/intermediate/marts convention (now the official best practice), added tags for domain ownership, and later shipped ‘dbt Mesh’ for multi-project federation. Also added `meta:` for ownership + PII flags on every model.
A team ran 800+ models nightly, all materialised as full tables. Total warehouse cost hit $20k/month. Run duration exceeded the CFO dashboard SLA.
Migrated the biggest 30 models to incremental with correct unique_key + is_incremental() gates. Added dbt source freshness alerts. Runtime dropped 8× and cost dropped ~60 %. Also introduced `dbt build --select state:modified+` in CI to only build changed models.
Fivetran adds a new column to the source. dbt staging model uses `select *` so the column silently flows through. Downstream analyst joins on the new column, cardinality explodes, dashboards inflate 10×. Nobody notices for a week.
(a) Never `select *` in staging — always enumerate columns. (b) Add `dbt-expectations.expect_table_column_count_to_equal` to source YML. (c) Use `on_schema_change: sync_all_columns` for incremental models so column changes are surfaced, not ignored.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- dbt in 30 seconds — model, ref(), test, DAG.
- Staging → intermediate → marts — one sentence per layer.
- The four materialisations — pick one for each of four workloads.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.