dbt (data build tool)

dbt turns a folder of SELECT statements into a tested, documented, dependency-ordered transformation pipeline that runs inside your warehouse. This page covers models and refs, materialisations, incremental models, tests and documentation, with a small runnable project.


1. What dbt Is

What dbt Does with Your SQL models/*.sql SELECT + Jinja {{ ref('stg_orders') }} dbt compile resolve refs, build DAG Warehouse CREATE VIEW / TABLE AS SELECT Snowflake, BigQuery, Databricks... dbt test assertions as queries dbt docs catalog + lineage graph dbt does the T in ELT; the warehouse does the work

dbt (data build tool) is an open-source framework for the transformation step of ELT. Data is first loaded into a warehouse or lakehouse; dbt then turns it into clean, tested, documented tables using nothing but SELECT statements. Each model is a SQL file. dbt wraps it in the right DDL (CREATE VIEW, CREATE TABLE AS, or a MERGE), figures out the order to run models from their dependencies, and executes them in the warehouse. dbt itself moves no data and has no compute engine: the warehouse does all the work.

It is available as dbt Core (open source, command line) and as a managed cloud product from dbt Labs. Adapters exist for Snowflake, BigQuery, Redshift, Databricks, Postgres, DuckDB and many others.

2. Why It Matters

Before tools like dbt, warehouse transformations were often stored procedures or SQL scripts run in a hand-maintained order, without tests or version control. dbt brought software-engineering practice to analytics SQL: code in Git with pull-request review, modular models that reference each other instead of copy-pasted subqueries, automated tests, environments (dev, CI, prod), and documentation generated from the code. It also lets analysts who know SQL own production transformations, which is why the role of "analytics engineer" grew up around it.

3. How It Works: Models, ref and Materialisations

Lineage DAG Built from ref() raw.orders raw.customers stg_orders stg_customers int_orders_enriched fct_orders dim_customers Dashed = sources (declared in sources.yml); edges come from ref() and source() calls.

Three concepts carry most of dbt:

The conventional project layout follows the layered design: staging models (one per source table, rename and cast), intermediate models (joins and business logic), and marts (facts and dimensions for consumers).

4. Incremental Models

Incremental Model: Process Only New Rows Source: all rows only rows newer than max(updated_at) in target is_incremental() filter + MERGE on unique_key Target table updated in place First run (or --full-refresh): builds the whole table. Later runs: only the new slice.

Rebuilding a billion-row table every hour is wasteful. An incremental model wraps a filter in {% if is_incremental() %} so that, on runs after the first, only rows newer than the latest already in the target are selected. With a unique_key, dbt merges them so updated rows replace old versions rather than duplicating them, which makes reruns idempotent. Strategies vary by adapter: merge, delete+insert, insert_overwrite (by partition) and append. A --full-refresh rebuilds from scratch when logic changes. Late-arriving data needs care: filtering on max(updated_at) misses records with older timestamps that land late, so many teams subtract a lookback window.

Snapshots and seeds. Two further building blocks round out a project. A snapshot watches a mutable source table and, on each run, records changed rows with dbt_valid_from and dbt_valid_to columns, giving you type 2 history even when the source system only keeps the current value. Snapshots should read from sources, not from transformed models, so that history captures what actually arrived. A seed is a small CSV file checked into the repository, such as a country-code mapping, that dbt loads as a table; seeds are for static reference data, not for loading real datasets.

Environments. Each developer works in a personal schema configured in their profile, so running models never touches production tables. Production jobs run the same code against the production target. Because every reference goes through ref(), the switch is purely configuration, and a pull request can be built and tested in an isolated CI schema before merging.

5. Worked Example Project

Two models, one staging and one incremental fact:

-- models/staging/stg_orders.sql
select
    id                        as order_id,
    customer_id,
    cast(total as numeric(10,2)) as order_total,
    status,
    updated_at
from {{ source('shop', 'orders') }}

-- models/marts/fct_orders.sql  (incremental)
{{ config(materialized='incremental', unique_key='order_id',
          incremental_strategy='merge') }}

select o.order_id, o.customer_id, c.country, o.order_total, o.status, o.updated_at
from {{ ref('stg_orders') }} o
join {{ ref('stg_customers') }} c using (customer_id)
{% if is_incremental() %}
  where o.updated_at > (select max(updated_at) from {{ this }})
{% endif %}

Sources and tests are declared in YAML. Each test compiles to a query that returns failing rows; zero rows means pass:

# models/marts/schema.yml
version: 2
sources:
  - name: shop
    schema: raw
    tables:
      - name: orders
      - name: customers
models:
  - name: fct_orders
    description: "One row per order, latest status."
    columns:
      - name: order_id
        tests: [unique, not_null]
      - name: customer_id
        tests:
          - relationships: {to: ref('stg_customers'), field: customer_id}
      - name: status
        tests:
          - accepted_values: {values: ['placed', 'shipped', 'returned']}

And the everyday commands:

dbt run   --select fct_orders+        # build fct_orders and everything downstream
dbt test  --select fct_orders         # run its tests
dbt build                             # run + test + seed + snapshot in DAG order
dbt run   --select fct_orders --full-refresh   # rebuild an incremental model from scratch
dbt docs generate && dbt docs serve   # browse catalog and lineage

The + graph operator selects a model plus its descendants, which is how you rebuild everything affected by a change. dbt build interleaves tests with model builds, so a failing test stops bad data from flowing into downstream models. In CI, a common pattern is "slim CI": build and test only models changed in the pull request and their children, comparing against the production manifest.

6. Comparison with Alternatives

ToolLanguageOrchestrationBest for
dbtSQL + Jinja (Python models on some adapters)Builds its own DAG; scheduled externally or by dbt CloudWarehouse-centric SQL transformation
SQLMeshSQL / PythonOwn DAG, virtual environments, plan/applyTeams wanting change-impact plans and cheap environments
Delta Live Tables / Lakeflow pipelinesSQL / PythonManaged on DatabricksStreaming + batch on Databricks
DataformSQLXIntegrated with BigQueryBigQuery-only shops
Stored procedures + schedulerSQLManual orderingSmall legacy workloads
Spark jobs in AirflowPython / ScalaAirflowHeavy non-SQL processing

dbt is not an orchestrator for ingestion or ML; it typically runs as one task in Airflow, Dagster or dbt Cloud, after loaders such as Fivetran or Airbyte.

7. Pitfalls

9. Further Reading