Education › Data › Stage 2: Pipelines

Transformations with dbt

Models, tests, sources, incremental models, documentation and a warehouse that explains itself.

Intermediate ~35 min read Module 7 of 16

Once raw data is in the warehouse, someone has to turn it into the tables people trust: cleaned, joined, conformed, documented. For years that was a folder of SQL scripts run in an order only one person understood. dbt made transformation a software discipline: models are SELECT statements, dependencies are inferred from references, tests run against every build, documentation is generated from the code, and the whole thing lives in git and runs in CI. This module covers the dbt project structure that scales, incremental models that do not reprocess the world, tests and sources, and the habits that keep a warehouse explainable as it grows past a few hundred models.

After this module you can
  • Structure a dbt project into staging, intermediate and mart layers with consistent naming
  • Write models with ref and source so the dependency graph builds itself
  • Use incremental materialisation correctly, including late data and full refreshes
  • Add tests, sources with freshness checks, and documentation that generates a lineage graph
  • Run dbt in CI against a slim build and deploy it on a schedule

Models, refs and the graph

A model is a SQL file containing one SELECT. dbt wraps it in the DDL for the chosen materialisation (view, table, incremental, ephemeral) and runs it in the warehouse. The important part is ref(): instead of hard-coding analytics.stg_orders, a model writes {{ ref('stg_orders') }}, and dbt resolves the name for the target environment and records the dependency. Run the project and dbt executes models in graph order; select a model with + and dbt runs everything it depends on. source() does the same for raw tables that other systems load, which is where lineage begins.

A staging model: one source, renamed and typed, nothing else. Every raw table gets exactly one of these.
sql
-- models/staging/shop/stg_shop__orders.sql
with source as (
    select * from {{ source('shop', 'orders') }}
),
renamed as (
    select
        id                                   as order_id,
        customer_id,
        cast(ordered_at as timestamp)        as ordered_at,
        lower(status)                        as status,
        cast(amount_cents as numeric) / 100  as amount,
        _loaded_at                           as loaded_at
    from source
)
select * from renamed
A mart model built from staging models by ref. dbt infers that both staging models must run first.
sql
-- models/marts/finance/fct_orders.sql
{{ config(materialized='table') }}

select
    o.order_id,
    o.customer_id,
    c.country,
    o.ordered_at,
    cast(o.ordered_at as date) as order_date,
    o.status,
    o.amount
from {{ ref('stg_shop__orders') }} o
left join {{ ref('stg_shop__customers') }} c using (customer_id)
where o.status != 'cancelled'
Note

dbt does not move data or run Python for you in the classic sense; it generates and runs SQL in your warehouse. The transformation logic is SQL, tested and versioned, and the warehouse does the work.

Project structure that survives growth

The convention most teams converge on has three layers. Staging (stg_<source>__<entity>): one model per raw table, light cleaning only — rename, cast, standardise; materialised as views. Intermediate (int_<purpose>): joins and reshaping that several marts share, not exposed to end users. Marts (fct_ facts and dim_ dimensions, grouped by business area): the tables dashboards and analysts query, materialised as tables or incremental. The rule is that data flows one way — sources to staging to intermediate to marts — and each layer references only the layer below or itself.

A project layout and naming that tells a reader what each model is before opening it.
text
models/
  staging/
    shop/
      _shop__sources.yml         source definitions, freshness, column docs
      _shop__models.yml          tests and docs for the staging models
      stg_shop__orders.sql
      stg_shop__customers.sql
    payments/
      ...
  intermediate/
    finance/
      int_orders_with_refunds.sql
  marts/
    finance/
      _finance__models.yml
      fct_orders.sql
      fct_refunds.sql
      dim_customers.sql
    marketing/
      ...
macros/
tests/                            singular tests (SQL that must return zero rows)
snapshots/                        SCD2 captures
dbt_project.yml                   materialisation defaults per folder

Set materialisation defaults per folder in dbt_project.yml (staging as views, marts as tables) so individual models rarely need config. Keep metrics logic in the marts, not in dashboards, and define each metric once — the modelling module's rule, now enforced by having one fct_orders that everybody refs.

Incremental models

Rebuilding a billion-row fact table every night is slow and expensive. An incremental model processes only new or changed rows on each run and merges them into the existing table. The model's SQL is filtered with an is_incremental() block on the first run (or a full refresh) it builds everything; afterwards it selects rows newer than what the table already holds. A unique_key tells dbt how to merge, so late or updated rows replace their earlier versions rather than duplicate them. Combine with a lookback window to absorb late data, exactly as the batch module described.

An incremental fact with a unique key and a three-day lookback. A full refresh rebuilds from scratch when logic changes.
sql
-- models/marts/events/fct_events.sql
{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge',
    on_schema_change='append_new_columns'
) }}

select
    event_id,
    user_id,
    event_name,
    event_at,
    cast(event_at as date) as event_date,
    properties
from {{ ref('stg_app__events') }}

{% if is_incremental() %}
  -- reprocess a trailing window so late-arriving events are picked up and merged by event_id
  where event_at >= (select max(event_at) from {{ this }}) - interval '3 days'
{% endif %}

Three cautions. The max() subquery over a huge table can itself be slow; partition-aware warehouses handle it, others benefit from a watermark stored elsewhere. Changing the model's logic does not rewrite old rows; run dbt run --full-refresh --select fct_events after a logic change and treat that as a backfill with the same care. And on_schema_change decides what happens when a column appears; the default (ignore) silently drops it, so choose deliberately.

Tip

Start every model as a table. Make it incremental only when the rebuild is measurably too slow or expensive; incremental logic is where most subtle bugs in a dbt project live.

Tests, sources and documentation

dbt tests are assertions that run as SQL and fail if any rows come back. Generic tests are declared in YAML on columns: unique, not_null, accepted_values, relationships (every customer_id in orders exists in customers). Singular tests are SQL files for business rules ("no order has a negative amount after refunds"). Run them after every build; a failing test on a mart should block downstream consumers, which dbt build does by running tests in graph order and stopping dependents on failure.

Source definition with freshness, and model tests and documentation, in one YAML file next to the models.
yaml
version: 2

sources:
  - name: shop
    schema: raw_shop
    loaded_at_field: _loaded_at
    freshness:
      warn_after: {count: 6, period: hour}
      error_after: {count: 24, period: hour}
    tables:
      - name: orders
      - name: customers

models:
  - name: fct_orders
    description: One row per non-cancelled order. Revenue is SUM(amount) where status in ('paid', 'shipped').
    columns:
      - name: order_id
        description: Natural key from the shop database.
        tests: [unique, not_null]
      - name: customer_id
        tests:
          - not_null
          - relationships: {to: ref('dim_customers'), field: customer_id}
      - name: status
        tests:
          - accepted_values: {values: ['placed', 'paid', 'shipped', 'refunded']}
      - name: amount
        tests:
          - dbt_utils.accepted_range: {min_value: 0, inclusive: true}

dbt source freshness checks that raw tables are being loaded, turning "the pipeline is silently stuck" into a failing check. dbt docs generate produces a site with every model's description, columns, tests and a lineage graph from sources to marts; it is the documentation people actually consult because it is generated from the code and cannot drift. Descriptions on marts should state the grain and the metric definitions, so the docs answer the questions the modelling module said to write down.

Running it: environments, CI and schedule

dbt reads a target (development, CI, production) that maps to a schema and credentials, so the same code builds into a developer's own schema locally and into the production schema on the scheduler. In CI, a pull request builds only the models it changed and their descendants, comparing against the production manifest — the slim CI pattern — and runs their tests, so a broken model never merges. Production runs are an orchestrator task (dbt build on a schedule, or after the raw loads land) with the manifest and run results stored for the next slim CI and for observability.

The three commands that matter: a slim CI build of what changed, the production build, and the freshness check that gates it.
bash
# CI: build changed models and their descendants against the last production manifest, test them
dbt build --select state:modified+ --state ./prod-manifest --target ci --fail-fast

# production: fail early if sources are stale, then build everything with tests in graph order
dbt source freshness --target prod
dbt build --target prod

# a logic change on an incremental model: rebuild it from scratch once
dbt run --full-refresh --select fct_events --target prod
  • Pin the dbt version and adapter in the project; upgrade deliberately.
  • Version packages (dbt_utils and friends) in packages.yml and commit the lock file.
  • Use the + and @ selectors and tags to run subsets; never run a 400-model project to test one change.
  • Store run artifacts (manifest, run results) after each production run; they feed slim CI, lineage tooling and the observability module's freshness dashboards.
  • Treat a test failure in production like a failing deploy: alert, block dependents, fix, rerun.
Hands-on practice

A tested, documented dbt project on local data

  1. Install dbt with the DuckDB adapter (pip install dbt-duckdb) and initialise a project. Load the orders and customers CSVs from earlier modules into a DuckDB file as raw tables.
  2. Define the two raw tables as a source with a loaded_at_field and freshness thresholds. Write stg_shop__orders and stg_shop__customers with renaming and casting only.
  3. Build dim_customers and fct_orders as marts with ref. Run dbt run and confirm the graph order in the output.
  4. Add unique, not_null, accepted_values and relationships tests. Introduce a duplicate order in the raw data and confirm dbt build fails and skips the mart's dependents.
  5. Convert fct_orders to an incremental model with unique_key and a lookback. Run it twice, add a late order to the raw table, run again and confirm it appears once.
  6. Generate docs and open the lineage graph. Add a description to fct_orders that states its grain and the revenue definition.
  7. Commit the project, store the manifest as prod-manifest, change one staging model, and run the slim CI command to confirm only the changed model and its descendants build.
Cheat sheet

Transformations with dbt — at a glance

Main things to focus on

  • Models are SELECTs; ref() and source() build the dependency graph and environment-aware names
  • Layers: staging (rename and cast, views) → intermediate (shared joins) → marts (fct_ and dim_, tables); data flows one way
  • Incremental only when needed: unique_key, merge, lookback window, deliberate on_schema_change, full refresh after logic changes
  • Tests in YAML on columns plus singular SQL tests; dbt build runs them in graph order and blocks dependents
  • Source freshness turns a stuck loader into a failing check; generated docs carry grain and metric definitions
  • Targets per environment; slim CI builds only what changed; production runs on a schedule with artifacts stored

Core syntax

{{ ref('model_name') }}Reference another model; records the dependency
{{ source('src', 'table') }}Reference a raw table defined in sources YAML
{{ config(materialized='table'|'view'|'incremental'|'ephemeral') }}How the model is built
{{ this }}The model's own table (used in incremental filters)
{% if is_incremental() %} where ... {% endif %}Only on incremental runs
{{ var('name', default) }}Runtime variables

Structure and naming

stg_<source>__<entity>Staging: one per raw table, light cleaning
int_<purpose>Intermediate: shared joins, not user-facing
fct_<event> / dim_<entity>Marts by business area
dbt_project.yml: models: +materialized per folderDefaults per layer
_<area>__models.yml / _<src>__sources.ymlTests and docs beside the models

Incremental

unique_key='id', incremental_strategy='merge'Late and updated rows replace, not duplicate
where ts >= (select max(ts) from {{ this }}) - interval '3 days'Trailing-window reprocessing
on_schema_change='append_new_columns'Default ignore silently drops new columns
dbt run --full-refresh --select modelRebuild after a logic change
snapshots/ with strategy='timestamp'|'check'SCD2 capture of a source

Tests, docs, running

tests: [unique, not_null, accepted_values, relationships]Generic column tests
tests/*.sql returning zero rowsSingular business-rule tests
dbt buildRun + test in graph order; skips dependents of failures
dbt source freshnessAre raw tables being loaded?
dbt docs generate && dbt docs serveLineage and column docs from code
dbt build --select state:modified+ --state ./prod-manifestSlim CI: changed models and descendants
--select model+ / +model / tag:dailyDescendants / ancestors / tagged subset

Common pitfalls

  • Hard-coding schema.table instead of ref(); the graph and environment switching break.
  • Business logic in staging models, so every mart inherits assumptions nobody documented.
  • Incremental models with no unique_key, duplicating late rows; or default on_schema_change dropping new columns.
  • Changing incremental logic without a full refresh; old rows keep the old logic.
  • Running the whole project in CI for every pull request; slow, expensive and eventually skipped.
  • Tests that exist but never block anything, because the schedule runs dbt run instead of dbt build.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →