Transform Data in the Warehouse with dbt
How to use dbt for SQL-based data transformations with models, tests, materializations, macros, and incremental loading in a data warehouse.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
dbt (data build tool) is a SQL-first transformation framework that turns your data warehouse into a version-controlled analytics engineering platform. You write SELECT statements in .sql files, and dbt handles materialization (table, view, incremental), dependency resolution, testing, and documentation. dbt compiles your models into SQL, runs them in the warehouse (BigQuery, Snowflake, Redshift, Postgres), and tracks lineage between models.
When to Use
- Transforming raw data in a warehouse into analytics-ready tables
- When you want version-controlled, testable SQL transformations
- Building a layered model architecture (staging → intermediate → marts)
- Teams where analysts write SQL but need software engineering practices
- When you need data lineage, documentation, and freshness checks
When NOT to Use
- Real-time/streaming transformations — dbt is batch-oriented
- Small datasets in pandas — use pandas/Polars directly
- ETL where extraction and loading are the bottleneck — dbt only does the T in ETL
- When you need complex procedural logic (loops, conditionals) — use stored procedures or Python
Solution
Project structure
dbt_project/
├── dbt_project.yml
├── profiles.yml
├── models/
│ ├── staging/
│ │ ├── stg_orders.sql
│ │ ├── stg_customers.sql
│ │ └── schema.yml
│ ├── intermediate/
│ │ └── int_orders_enriched.sql
│ └── marts/
│ ├── fct_orders.sql
│ ├── dim_customers.sql
│ └── schema.yml
├── macros/
│ └── cents_to_dollars.sql
├── tests/
│ └── assert_order_status.sql
└── snapshots/
└── snap_customers.sql
Basic model
-- models/staging/stg_orders.sql
SELECT
order_id::integer AS order_id,
customer_id::integer AS customer_id,
order_date::date AS order_date,
amount::numeric(10,2) AS amount,
status::varchar AS status
FROM {{ source('raw', 'orders') }}
Source definitions
# models/staging/schema.yml
version: 2
sources:
- name: raw
database: warehouse
schema: raw_data
tables:
- name: orders
columns:
- name: order_id
tests:
- unique
- not_null
- name: amount
tests:
- not_null
- name: customers
columns:
- name: customer_id
tests:
- unique
- not_null
Model with references
-- models/marts/fct_orders.sql
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.amount,
o.status,
c.customer_name,
c.customer_tier,
c.city
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_customers') }} c
ON o.customer_id = c.customer_id
WHERE o.status = 'completed'
ref() creates a dependency — dbt runs stg_orders and stg_customers before fct_orders.
Materializations
# dbt_project.yml
models:
my_project:
staging:
+materialized: view
intermediate:
+materialized: ephemeral
marts:
+materialized: table
fct_orders:
+materialized: incremental
-- models/marts/fct_orders.sql — incremental model
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge'
) }}
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.amount
FROM {{ ref('stg_orders') }} o
WHERE o.status = 'completed'
{% if is_incremental() %}
AND o.order_date > (SELECT MAX(order_date) FROM {{ this }})
{% endif %}
Schema tests
# models/marts/schema.yml
version: 2
models:
- name: fct_orders
description: "Completed orders with customer details"
columns:
- name: order_id
tests:
- unique
- not_null
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: amount
tests:
- not_null
- dbt_utils.expression_is_true:
expression: "amount >= 0"
- name: status
tests:
- accepted_values:
values: ['completed', 'pending', 'cancelled']
Custom singular test
-- tests/assert_no_negative_amounts.sql
SELECT *
FROM {{ ref('fct_orders') }}
WHERE amount < 0
Macros
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name) %}
({{ column_name }} / 100.0)
{% endmacro %}
-- Using the macro in a model
SELECT
order_id,
{{ cents_to_dollars('amount_cents') }} AS amount_dollars
FROM {{ ref('stg_orders') }}
Snapshots for SCD Type 2
-- snapshots/snap_customers.sql
{% snapshot snap_customers %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at',
)
}}
SELECT * FROM {{ source('raw', 'customers') }}
{% endsnapshot %}
Snapshots track changes to source data over time, creating a history table with valid_from, valid_to, and dbt_scd_id columns.
Using variables
-- models/marts/fct_orders.sql
SELECT *
FROM {{ ref('stg_orders') }}
WHERE order_date >= '{{ var('start_date', '2025-01-01') }}'
dbt run --vars '{"start_date": "2025-06-01"}'
Hooks
# dbt_project.yml
on-run-start:
- "CREATE SCHEMA IF NOT EXISTS {{ target.schema }}"
on-run-end:
- "{{ log('Model run complete', info=True) }}"
Variants
Using dbt with Airflow
from airflow import DAG
from airflow.operators.bash import BashOperator
from datetime import datetime
dag = DAG("dbt_pipeline", schedule_interval="@daily", start_date=datetime(2025, 1, 1))
dbt_run = BashOperator(
task_id="dbt_run",
bash_command="cd /opt/dbt && dbt run --select marts.*",
dag=dag,
)
dbt_test = BashOperator(
task_id="dbt_test",
bash_command="cd /opt/dbt && dbt test --select marts.*",
dag=dag,
)
dbt_run >> dbt_test
Custom materialization with post-hook
{{ config(
materialized='table',
post_hook="CREATE INDEX IF NOT EXISTS idx_orders_date ON {{ this }} (order_date)"
) }}
SELECT * FROM {{ ref('stg_orders') }}
Packages
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: 1.1.1
- package: calogica/dbt_expectations
version: 0.10.3
dbt deps
-- Using dbt_utils
{{ dbt_utils.date_spine(
datepart="day",
start_date="'2025-01-01'",
end_date="'2025-12-31'"
) }}
Best Practices
-
For a deeper guide, see Schedule and Monitor DAGs with Apache Airflow.
-
Use a layered architecture: staging (cleaning) → intermediate (joins) → marts (business logic)
-
Always test primary keys with
uniqueandnot_null— catches data quality issues early -
Use
ephemeralmaterialization for intermediate models used only once — avoids storage -
Use
incrementalfor large fact tables — only process new rows, not the full history -
Document every model and column —
dbt docs generatecreates a documentation site -
Use
ref()instead of hardcoding table names — dbt resolves dependencies and order -
Use sources for raw data and
ref()for dbt models — separates external from internal -
Run
dbt testin CI — fail the pipeline on data quality issues
Common Mistakes
- Not testing models: without tests, bad data propagates silently. Always test primary keys and critical fields.
- Using
tablematerialization for everything: views are cheaper for models queried infrequently. Usetableonly for models queried often. - Not using incremental for large tables: full refresh on a 100M row table is slow. Use
incrementalwith aunique_key. - Hardcoding schema names: use
{{ target.schema }}or{{ this }}— enables multi-environment deployments. - Not using packages:
dbt_utilsanddbt_expectationsprovide tested macros for common patterns.
FAQ
What is the difference between a source and a ref?
source() references raw tables loaded by an external process (Fivetran, Airflow, custom ETL). ref() references other dbt models. dbt builds a DAG from both to determine execution order.
How do I run specific models?
dbt run --select stg_orders # One model
dbt run --select marts.* # All models in marts/
dbt run --select stg_orders+ # stg_orders and all downstream
dbt run --select +fct_orders # fct_orders and all upstream
What is an ephemeral model?
An ephemeral model doesn’t create a database object — dbt inlines its SQL as a CTE in downstream models. Use it for intermediate transformations used by only one or two models.
How do I handle slowly changing dimensions?
Use snapshots. dbt tracks changes to source rows using a timestamp or checksum strategy, creating a history table with validity periods.
Can I use dbt with Python?
Yes, dbt supports Python models in warehouses that support them (Snowflake, BigQuery, Databricks). Python models use DataFrames for transformations:
def model(dbt, session):
df = dbt.ref("stg_orders")
return df.filter(df.status == "completed") Related Resources
Build an ETL Pipeline with pandas and Parquet
How to build an extract-transform-load pipeline using pandas for data processing and Parquet for columnar storage with type coercion and validation.
RecipeRecursive CTEs for Hierarchical Data Queries
How to query hierarchical data with recursive Common Table Expressions in SQL, covering tree traversal, org charts, category trees, and cycle detection.
RecipeSchedule and Monitor DAGs with Apache Airflow
How to define, schedule, and monitor Directed Acyclic Graphs in Apache Airflow with operators, sensors, XCom, and task dependencies.
RecipeValidate DataFrame Schemas with Pandera
How to validate pandas and Polars DataFrame schemas with Pandera, covering column types, constraints, custom checks, hypothesis testing, and schema inheritance.