Search Tech Journey

Find topics, journeys and posts

6-month learning plan51 / 130
back to blog
data engineeringintermediate 55m read

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.

🗄️Data EngineeringM05 · Data Engineering· Session 051 of 130 90 min

🎯 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.

You will be able to
  • 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

Git for SQL
🌍 Real world

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.

💻 Code world

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

dbt's mental model
  • 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

  1. 1990s
    ETL rules — Informatica, DataStage
    Transform outside the warehouse; load pre-modelled tables. Slow to iterate, high license fees.
  2. 2012
    BigQuery + Redshift make warehouses cheap
    Now transforming inside the warehouse is economically viable.
  3. 2016
    dbt open-sourced by Fishtown Analytics
    Tristan Handy et al. build the ELT tool. Grows via analytics-engineer word of mouth.
  4. 2019
    dbt Labs founded, $1B valuation
    ‘Analytics engineer’ becomes an official job title.
  5. 2021
    Fivetran + dbt + Snowflake = ‘Modern Data Stack’
    The default architecture for every new startup.
  6. 2024
    dbt Mesh, dbt Cloud IDE, semantic layer
    Multi-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

view

CREATE VIEW

  • No storage cost
  • Recomputes on every SELECT
  • Great for staging, cheap sources
  • Bad for expensive transforms
table

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)
incremental

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
ephemeral

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

unique
Column has no duplicates. Every fact/dim needs one on its primary key.
generic
not_null
Column has no NULLs. Every FK column needs one.
generic
accepted_values
Column value ∈ enum set. Great for status, currency, country codes.
generic
relationships
FK integrity — every value in child exists in parent. Cross-model consistency check.
generic
dbt-utils.expression_is_true
Arbitrary boolean over one row (e.g. amount \>= 0). Wraps SQL nicely.
package
singular test
A .sql file in tests/ that returns rows if the invariant is violated. Ultimate flexibility.
custom

Common misconception
✗ What most people think

"dbt is a transformation engine. It runs my SQL, manages the compute, and orchestrates my pipeline."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.sql

The run/ version is just a create or replace table ... as (...) wrapper. That string is 100% of what dbt contributes at execution time.

From first principles
Start with the question

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.

  1. 1
    ETL 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
  2. 2
    So 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
  3. 3
    Cloud 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
  4. 4
    Once 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
  5. 5
    Meanwhile 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

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.

Mental modeldbt is a Makefile for SQL

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.
🔔 Fires when you see

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.

The tradeoff

A fact table grows by tens of millions of rows a day. Materialize it as table (full rebuild every run) or incremental?

Full rebuild (table)
+ you gain the output is a pure function of the sources. A logic fix takes effect everywhere on the next run, late-arriving and back-dated records are absorbed for free, and there is no merge key to get wrong. A backfill is just "run it".
− you pay compute scales with total history, not with new data. Three years of history costs roughly a thousand times a single day's worth to rebuild, and that cost grows every day whether or not anything changed.
pick when while a full rebuild still fits comfortably inside your SLA window and budget — measure it, and set an alert on the runtime rather than guessing
Incremental
+ you gain run cost tracks new data, so it stays roughly flat as history grows. This is the only shape in which a daily-refresh model survives multi-year history.
− you pay you now own correctness. The 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.
pick when when rebuild runtime or cost exceeds your window AND you can state a bounded lateness for the source, e.g. "events are never more than three days late"
Incremental with lookback + merge
+ you gain reprocess a trailing window (say seven days) and merge on a unique key: cost stays bounded while late arrivals and corrections inside the window self-heal without intervention.
− you pay 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.
pick when when the source genuinely has late arrivals or mutable records, and a unique test on the merge key has run in production and never failed
What a senior engineer actually does

Start 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:

my_dbt/ dbt_project.yml profiles.yml (kept OUTSIDE repo in ~/.dbt/) models/ sources.yml staging/ stg_orders.sql stg_customers.sql _staging.yml marts/ fct_orders.sql dim_customers.sql _marts.yml tests/ assert_positive_amount.sql

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: 4

models/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 null

models/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 src

models/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,4

models/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 <= 0

What each block does

Anatomy of the project

dbt_project.yml → +materialized:
Layer defaults. Staging = views (cheap). Marts = tables (fast to query). Override per model with a config() block.
config
sources.yml + freshness
Declares raw tables + expected freshness. dbt source freshness command alerts if data is stale. Free monitoring.
source
ref() everywhere
Never hardcode table names. ref() lets dbt build the dependency DAG and rewrites table names per environment (dev/staging/prod).
critical
incremental + is_incremental()
Full run on first build; incremental thereafter. unique_key allows MERGE. Backfill by dropping the target table and re-running.
materialisation
relationships test
Referential integrity across models. Catches the ‘orphan customer_id in fct_orders’ bug automatically.
test
singular test in tests/
Any invariant expressible as ‘SELECT rows that break the rule’. This is where custom business rules live.
test
Try itBreak a test on purpose and watch CI catch it

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_orders unique test passes (the source stage tolerates a paid negative row because we only test the presence of a value).
  • fct_orders unique test fails on the duplicate order_id.
  • assert_positive_amount also fails on the negative amount.
  • dim_customers and any downstream model marked as depending on fct_orders is not built (unless --warn-error off).

This is the ELT circuit-breaker in action.

💡 Hint · Insert a duplicate order_id into raw.orders. Re-run `dbt build`. The unique test on fct_orders.order_id fails; downstream models skip; docs show the failed test in red.

(d) Production reality · 15 min

War story dbt Labs (themselves)documented in ‘State of Analytics Engineering’ report
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Convention beats configuration. Adopt the staging/intermediate/marts + one-fact-per-file pattern from day 1. Retrofitting it takes months.
Post-mortem
War story A large fintech · reported at Coalesce 202216-hour dbt run at the peak
🔥 What broke

A team ran 800+ models nightly, all materialised as full tables. Total warehouse cost hit $20k/month. Run duration exceeded the CFO dashboard SLA.

🧯 The fix

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.

🎓 Lesson to steal
Materialisation choice is the biggest cost lever. Small dim = table. Large fact = incremental. Rewriting all facts as tables ‘to keep it simple’ is a real waste of money.
War story Common failure — silent schema driftevery team, eventually
🔥 What broke

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.

🧯 The fix

(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.

🎓 Lesson to steal
Schema drift is a data-quality bug that only tests can catch. Explicit column selection + column-count assertions catch it at build time.

Where this shows up in the rest of the plan

dbt is the terminus of the batch pipeline
S052 · Warehousing on Snowflake / BigQuery
dbt targets these directly; adapter magic hides differences.
S053 · Data quality with Great Expectations
Complement to dbt tests for row-level and distribution checks.
S054 · Data contracts
dbt 1.5+ has model contracts (types + constraints).
S055 · CDC ingestion (Debezium/Fivetran)
Feeds the raw layer that dbt refs as sources.
S099 · ML feature stores
Features frequently start as dbt marts before promotion.
S123 · System design — analytics
Every serious BI system uses dbt at the transform layer.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. dbt in 30 seconds — model, ref(), test, DAG.
  2. Staging → intermediate → marts — one sentence per layer.
  3. 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.