LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

dbt Core in Practice: Data Modeling That Doesn't Rot

dbtdata-engineeringsqlanalyticsdata-modelingpostgresbigquerysnowflake

SQL analytics code has a reputation for rotting. A clever CTE someone wrote in 2021 now powers three dashboards, nobody knows how it works, it references tables that were renamed, and the column revenue_adj means something different depending on who you ask. Half the team has their own version of the same transformation living in a Jupyter notebook.

dbt (data build tool) is the answer the data engineering community converged on: treat your SQL transformations the way software engineers treat application code. Version control. Tests. Documentation. Modular, reusable models. Dependency graphs. Automated runs.

This guide covers dbt Core (the open-source CLI) from first principles through production deployment — project structure, the staging/mart modeling pattern, source freshness checks, data quality tests, incremental models for large tables, documentation, and running dbt in production with Airflow.

What dbt Actually Does

dbt does exactly one thing: it takes SQL SELECT statements you write and runs them against your data warehouse, materializing the results as tables or views. That’s it. The transformation logic lives in your SQL; dbt handles compilation, dependency ordering, testing, and documentation.

What makes this powerful:

  • ref() and source(): Instead of hardcoding table names, you use {{ ref('my_model') }} and {{ source('raw', 'orders') }}. dbt resolves these to the correct schema/database and builds a full dependency DAG.
  • Materialization: Each model can be a view (recomputed on query), a table (rebuilt on each run), or incremental (only new rows processed).
  • Tests: Write assertions about your data — uniqueness, non-null, referential integrity, accepted values — and dbt runs them automatically.
  • Documentation: Describe every model and column in YAML; dbt generates a browsable data catalog.

dbt does not move data. It does not connect to source systems. It assumes data is already in your warehouse (loaded by Fivetran, Airbyte, custom ingestion, etc.) and transforms it in-place.

Installation and Setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Install dbt Core with your warehouse adapter
pip install dbt-core dbt-postgres      # PostgreSQL / Redshift
pip install dbt-core dbt-bigquery      # Google BigQuery
pip install dbt-core dbt-snowflake     # Snowflake
pip install dbt-core dbt-duckdb        # DuckDB (great for local dev)
pip install dbt-core dbt-spark         # Apache Spark
pip install dbt-core dbt-databricks    # Databricks

# Or install everything for a multi-warehouse setup
pip install dbt-core dbt-postgres dbt-bigquery

# Verify
dbt --version
# Core: 1.8.x
# Installed adapters: postgres, bigquery

Initialize a Project

1
2
dbt init analytics
cd analytics

This creates:

analytics/
├── dbt_project.yml          # Project config
├── profiles.yml             # Connection config (usually in ~/.dbt/)
├── models/                  # Your SQL transformations
├── tests/                   # Custom data tests
├── macros/                  # Reusable Jinja macros
├── seeds/                   # Static CSV data
├── snapshots/               # SCD type 2 history tables
└── analyses/                # Ad-hoc queries (not materialized)

Connection Profile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ~/.dbt/profiles.yml
analytics:
  target: dev
  outputs:
    dev:
      type: postgres
      host: localhost
      port: 5432
      user: "{{ env_var('DBT_USER', 'analytics') }}"
      password: "{{ env_var('DBT_PASSWORD') }}"
      database: analytics
      schema: dbt_dev_yourname   # Each dev gets their own schema
      threads: 4

    prod:
      type: postgres
      host: prod-warehouse.company.com
      port: 5432
      user: dbt_prod
      password: "{{ env_var('DBT_PROD_PASSWORD') }}"
      database: analytics
      schema: analytics
      threads: 8
1
2
3
4
5
# Test the connection
dbt debug

# Run with the prod target
dbt run --target prod

Project Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# dbt_project.yml
name: analytics
version: '1.0.0'
config-version: 2

profile: analytics

# Directory structure
model-paths: ["models"]
test-paths: ["tests"]
macro-paths: ["macros"]
seed-paths: ["seeds"]
snapshot-paths: ["snapshots"]

# Target directory for compiled SQL
target-path: "target"
clean-targets: ["target", "dbt_packages"]

# Default materializations per folder
models:
  analytics:
    # staging/ models: cheap views, rebuilt each run
    staging:
      +materialized: view
      +schema: staging

    # intermediate/ models: views for complex joins
    intermediate:
      +materialized: view
      +schema: intermediate

    # marts/ models: tables that dashboards query
    marts:
      +materialized: table
      +schema: marts
      core:
        +materialized: table
      finance:
        +materialized: table

The Layered Modeling Architecture

The single most important dbt practice is the staging → intermediate → marts layer pattern. It keeps your project navigable as it grows.

raw data (loaded by Fivetran/Airbyte)
    └── staging/         # One model per source table, light cleanup only
            └── intermediate/    # Complex joins, business logic
                    └── marts/   # Final tables for BI tools, one per business domain

Layer 1: Staging

Staging models do one job: rename columns, cast types, and apply light transformations on a single source table. No joins. No business logic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-- models/staging/stripe/stg_stripe__payments.sql
-- One staging model per source table.
-- Naming: stg_{source}__{table}

WITH source AS (
    -- Reference the raw source table, not a hardcoded name
    SELECT * FROM {{ source('stripe', 'payments') }}
),

renamed AS (
    SELECT
        -- Rename to our conventions
        id                          AS payment_id,
        created                     AS created_at,
        amount                      AS amount_cents,

        -- Cast and clean
        amount / 100.0              AS amount_dollars,
        LOWER(currency)             AS currency,
        LOWER(status)               AS payment_status,

        -- Extract useful derived columns
        customer_id,
        charge_id,
        invoice_id,

        -- Metadata
        _airbyte_ab_id              AS _loaded_at
    FROM source
)

SELECT * FROM renamed
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- models/staging/postgres/stg_postgres__orders.sql
WITH source AS (
    SELECT * FROM {{ source('postgres', 'orders') }}
),

renamed AS (
    SELECT
        order_id,
        customer_id,
        status                          AS order_status,
        CAST(ordered_at AS TIMESTAMP)   AS ordered_at,
        CAST(fulfilled_at AS TIMESTAMP) AS fulfilled_at,
        subtotal_cents / 100.0          AS subtotal,
        tax_cents / 100.0               AS tax,
        shipping_cents / 100.0          AS shipping,
        (subtotal_cents + tax_cents + shipping_cents) / 100.0 AS total_amount
    FROM source
    WHERE order_id IS NOT NULL  -- Filter obvious garbage at ingest
)

SELECT * FROM renamed

Defining Sources

Sources describe your raw data and enable lineage tracking and freshness monitoring:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# models/staging/stripe/_stripe__sources.yml
version: 2

sources:
  - name: stripe
    description: "Stripe payment data loaded by Fivetran"
    database: raw_data
    schema: stripe
    loader: fivetran

    # Check that raw data is arriving on time
    freshness:
      warn_after: {count: 6, period: hour}
      error_after: {count: 24, period: hour}
    loaded_at_field: _fivetran_synced

    tables:
      - name: payments
        description: "All payment attempts, successful and failed"
        columns:
          - name: id
            description: "Stripe payment intent ID"
            tests:
              - unique
              - not_null
          - name: status
            tests:
              - accepted_values:
                  values: ['succeeded', 'pending', 'failed', 'canceled']
          - name: amount
            description: "Amount in smallest currency unit (cents for USD)"
            tests:
              - not_null
              - dbt_utils.accepted_range:
                  min_value: 0

      - name: customers
        description: "Stripe customer records"
        freshness:
          warn_after: {count: 12, period: hour}
        columns:
          - name: id
            tests: [unique, not_null]
1
2
# Check source freshness (runs before dbt run in production)
dbt source freshness

Layer 2: Intermediate

Intermediate models assemble staging models into reusable building blocks. These are the complex joins and denormalizations that multiple mart models might need:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
-- models/intermediate/int_orders__with_payments.sql
-- Joins orders with their payment records.
-- Used by multiple mart models — defined once here.

WITH orders AS (
    SELECT * FROM {{ ref('stg_postgres__orders') }}
),

payments AS (
    SELECT * FROM {{ ref('stg_stripe__payments') }}
    WHERE payment_status = 'succeeded'
),

order_payments AS (
    SELECT
        o.order_id,
        o.customer_id,
        o.order_status,
        o.ordered_at,
        o.fulfilled_at,
        o.subtotal,
        o.tax,
        o.shipping,
        o.total_amount,

        -- Payment details
        p.payment_id,
        p.amount_dollars       AS amount_paid,
        p.currency,
        p.created_at           AS paid_at,

        -- Derived
        CASE
            WHEN p.payment_id IS NULL THEN FALSE
            ELSE TRUE
        END                    AS is_paid,

        DATEDIFF('day', o.ordered_at, o.fulfilled_at) AS days_to_fulfill

    FROM orders o
    LEFT JOIN payments p ON o.order_id = p.invoice_id
)

SELECT * FROM order_payments

Layer 3: Marts

Mart models are the final output — wide, denormalized tables optimized for BI tools. Organized by business domain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
-- models/marts/core/dim_customers.sql
-- Customer dimension table: one row per customer, current state.

WITH customers AS (
    SELECT * FROM {{ ref('stg_postgres__customers') }}
),

orders AS (
    SELECT * FROM {{ ref('int_orders__with_payments') }}
),

customer_orders AS (
    SELECT
        customer_id,
        COUNT(*)                                AS total_orders,
        SUM(total_amount)                       AS lifetime_value,
        MIN(ordered_at)                         AS first_order_at,
        MAX(ordered_at)                         AS most_recent_order_at,
        COUNT(CASE WHEN is_paid THEN 1 END)     AS paid_orders,
        AVG(total_amount)                       AS avg_order_value
    FROM orders
    GROUP BY customer_id
),

final AS (
    SELECT
        c.customer_id,
        c.email,
        c.first_name,
        c.last_name,
        c.created_at                            AS customer_since,
        c.country,
        c.plan_type,

        -- Aggregated order behavior
        COALESCE(co.total_orders, 0)            AS total_orders,
        COALESCE(co.lifetime_value, 0)          AS lifetime_value,
        COALESCE(co.paid_orders, 0)             AS paid_orders,
        co.first_order_at,
        co.most_recent_order_at,
        co.avg_order_value,

        -- Segmentation
        CASE
            WHEN co.total_orders IS NULL THEN 'prospect'
            WHEN co.total_orders = 1 THEN 'new'
            WHEN co.most_recent_order_at >= CURRENT_DATE - INTERVAL '90 days'
                THEN 'active'
            ELSE 'lapsed'
        END                                     AS customer_segment,

        -- Metadata
        CURRENT_TIMESTAMP                       AS dbt_updated_at
    FROM customers c
    LEFT JOIN customer_orders co USING (customer_id)
)

SELECT * FROM final
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
-- models/marts/finance/fct_revenue.sql
-- Revenue fact table: one row per transaction for finance reporting.

WITH order_payments AS (
    SELECT * FROM {{ ref('int_orders__with_payments') }}
),

final AS (
    SELECT
        payment_id,
        order_id,
        customer_id,

        -- Time dimensions
        paid_at,
        DATE_TRUNC('month', paid_at)        AS revenue_month,
        DATE_TRUNC('week', paid_at)         AS revenue_week,
        EXTRACT(YEAR FROM paid_at)          AS revenue_year,

        -- Revenue breakdown
        subtotal                            AS gross_revenue,
        tax,
        shipping,
        total_amount,
        amount_paid,

        -- Flags
        currency,
        order_status,
        days_to_fulfill
    FROM order_payments
    WHERE is_paid
)

SELECT * FROM final

Testing Data Quality

Tests are where dbt prevents silent data corruption. There are two types: schema tests (YAML-defined assertions) and singular tests (custom SQL).

Schema Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# models/marts/core/_core__models.yml
version: 2

models:
  - name: dim_customers
    description: "One row per customer with aggregated order behavior"
    columns:
      - name: customer_id
        description: "Primary key — Postgres customer ID"
        tests:
          - unique
          - not_null

      - name: email
        tests:
          - unique
          - not_null
          - dbt_utils.expression_is_true:
              expression: "email LIKE '%@%'"

      - name: customer_segment
        tests:
          - not_null
          - accepted_values:
              values: ['prospect', 'new', 'active', 'lapsed']

      - name: lifetime_value
        tests:
          - not_null
          - dbt_utils.accepted_range:
              min_value: 0

  - name: fct_revenue
    description: "One row per successful payment transaction"
    columns:
      - name: payment_id
        tests:
          - unique
          - not_null

      - name: total_amount
        tests:
          - not_null
          - dbt_utils.accepted_range:
              min_value: 0

    # Model-level tests
    tests:
      # No revenue rows without a matching customer
      - dbt_utils.expression_is_true:
          expression: "total_orders >= paid_orders"
          model: ref('dim_customers')

Singular Tests (Custom SQL)

For complex assertions that don’t fit YAML:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- tests/assert_revenue_matches_orders.sql
-- Revenue fact table total should match order totals within 1% tolerance.
-- Fails if the query returns any rows.

WITH revenue_total AS (
    SELECT SUM(total_amount) AS total FROM {{ ref('fct_revenue') }}
),

order_total AS (
    SELECT SUM(total_amount) AS total
    FROM {{ ref('int_orders__with_payments') }}
    WHERE is_paid
),

comparison AS (
    SELECT
        r.total AS revenue_total,
        o.total AS order_total,
        ABS(r.total - o.total) / NULLIF(o.total, 0) AS discrepancy_pct
    FROM revenue_total r
    CROSS JOIN order_total o
)

-- Return rows when the discrepancy exceeds 1%
SELECT *
FROM comparison
WHERE discrepancy_pct > 0.01
1
2
3
4
5
6
-- tests/assert_no_future_orders.sql
-- Orders should not have ordered_at in the future.

SELECT order_id, ordered_at
FROM {{ ref('stg_postgres__orders') }}
WHERE ordered_at > CURRENT_TIMESTAMP

Running Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Run all tests
dbt test

# Test a specific model
dbt test --select dim_customers

# Test a model and all its upstream dependencies
dbt test --select +dim_customers

# Run only schema tests (not singular tests)
dbt test --select "test_type:generic"

# Check source freshness + run tests in one command
dbt source freshness && dbt test

Incremental Models

For large tables (billions of rows of events, logs, or transactions), rebuilding from scratch on every run is too slow. Incremental models process only new or updated rows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
-- models/marts/core/fct_events.sql
-- Event tracking table: potentially hundreds of millions of rows.
-- Only process new events on each run.

{{
    config(
        materialized='incremental',
        unique_key='event_id',
        incremental_strategy='merge',   -- 'append', 'merge', 'delete+insert'
        on_schema_change='append_new_columns'
    )
}}

WITH events AS (
    SELECT * FROM {{ source('segment', 'events') }}
    {% if is_incremental() %}
    -- On incremental runs, only process new events
    -- The filter uses the max value already in the table
    WHERE received_at > (SELECT MAX(received_at) FROM {{ this }})
    {% endif %}
),

final AS (
    SELECT
        id              AS event_id,
        anonymous_id,
        user_id,
        event           AS event_name,
        received_at,
        sent_at,
        properties,
        context_page_url,
        context_page_title
    FROM events
    WHERE id IS NOT NULL
)

SELECT * FROM final

Incremental Strategies

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- append: only add new rows (fastest, no deduplication)
{{ config(materialized='incremental', incremental_strategy='append') }}

-- merge: upsert based on unique_key (handles late-arriving updates)
{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge'
) }}

-- delete+insert: delete matching partition then insert (good for date-partitioned tables)
{{ config(
    materialized='incremental',
    unique_key=['date_day', 'user_id'],
    incremental_strategy='delete+insert',
    partition_by={'field': 'date_day', 'data_type': 'date'}
) }}

Full Refresh

When the logic changes or you need to rebuild from scratch:

1
2
3
4
5
# Rebuild a single incremental model from scratch
dbt run --select fct_events --full-refresh

# Rebuild all incremental models
dbt run --full-refresh

Macros and Reusable Logic

Macros are Jinja functions you write once and call from any model. They’re the equivalent of utility functions in application code.

1
2
3
4
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name, precision=2) %}
    ROUND({{ column_name }} / 100.0, {{ precision }})
{% endmacro %}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- macros/date_spine.sql
-- Generate a date range table for calendar joins
{% macro generate_date_spine(start_date, end_date) %}
    SELECT
        CAST(date_day AS DATE) AS date_day,
        EXTRACT(YEAR FROM date_day) AS year,
        EXTRACT(MONTH FROM date_day) AS month,
        EXTRACT(DOW FROM date_day) AS day_of_week,
        date_day >= CURRENT_DATE AS is_future
    FROM (
        SELECT
            CAST('{{ start_date }}' AS DATE) + CAST(generate_series AS INTEGER) AS date_day
        FROM generate_series(
            0,
            DATEDIFF('day',
                CAST('{{ start_date }}' AS DATE),
                CAST('{{ end_date }}' AS DATE)
            )
        )
    ) dates
{% endmacro %}
1
2
3
4
5
6
-- Using macros in models
SELECT
    order_id,
    {{ cents_to_dollars('subtotal_cents') }} AS subtotal,
    {{ cents_to_dollars('tax_cents') }} AS tax
FROM {{ ref('stg_postgres__orders') }}

Generic Tests as Macros

Write a custom generic test once, use it everywhere:

1
2
3
4
5
6
-- macros/test_not_negative.sql
{% test not_negative(model, column_name) %}
    SELECT {{ column_name }}
    FROM {{ model }}
    WHERE {{ column_name }} < 0
{% endtest %}
1
2
3
4
5
# Use it anywhere
columns:
  - name: total_amount
    tests:
      - not_negative

dbt Packages

Install community packages for common functionality:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# packages.yml
packages:
  - package: dbt-labs/dbt_utils
    version: 1.2.0
  - package: calogica/dbt_expectations
    version: 0.10.3
  - package: dbt-labs/audit_helper
    version: 0.9.0
  - package: brooklyn-data/dbt_artifacts
    version: 2.9.0
1
dbt deps  # Install packages

dbt_utils: surrogate_key(), safe_divide(), pivot(), date_spine(), accepted_range test, expression_is_true test.

dbt_expectations: Great Expectations-inspired tests — expect_column_values_to_be_between, expect_column_pair_values_to_be_equal, expect_table_row_count_to_be_between.

audit_helper: Compare two versions of a model before deploying, check for column changes, validate refactors.

Documentation

dbt generates a browsable data catalog from YAML descriptions you write alongside your models:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# models/marts/core/_core__models.yml
version: 2

models:
  - name: dim_customers
    description: >
      Customer dimension table. One row per customer.
      Includes aggregated lifetime order behavior.
      Used by: Revenue dashboards, Customer success Looker explores.

    meta:
      owner: "@data-team"
      contains_pii: true
      sla: "refreshed every 6 hours"

    columns:
      - name: customer_id
        description: "Primary key. Integer ID from the Postgres customers table."

      - name: customer_segment
        description: >
          Behavioral segment based on order recency.
          - prospect: no orders yet
          - new: exactly 1 order
          - active: ordered in the last 90 days
          - lapsed: ordered before, not in 90 days

      - name: lifetime_value
        description: "Sum of all successful order totals in USD."
1
2
3
4
5
6
7
8
9
# Generate and serve docs locally
dbt docs generate
dbt docs serve  # Opens http://localhost:8080

# The docs site includes:
# - Lineage DAG (visual dependency graph)
# - Model descriptions and column docs
# - Source freshness status
# - Test results

Snapshots: Slowly Changing Dimensions

Snapshots track how rows change over time — essential for Type 2 SCDs (keeping history when a value changes):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- snapshots/snap_customers.sql
{% snapshot snap_customers %}

{{
    config(
        target_schema='snapshots',
        unique_key='customer_id',
        strategy='timestamp',       -- or 'check' for tables without updated_at
        updated_at='updated_at',
        invalidate_hard_deletes=True
    )
}}

SELECT * FROM {{ source('postgres', 'customers') }}

{% endsnapshot %}
1
2
# Run snapshots (separate from dbt run)
dbt snapshot

Each time a customer’s plan_type changes, dbt inserts a new row with the previous value’s dbt_valid_to set to now and a new row with dbt_valid_from set to now. You can query “what plan was this customer on last month?” trivially.

Seeds: Static Reference Data

Small, rarely-changing reference tables (country codes, product categories, exchange rates) can live as CSV files in your repo:

1
2
3
4
5
6
-- seeds/country_codes.csv
country_code,country_name,region
US,United States,North America
GB,United Kingdom,Europe
DE,Germany,Europe
JP,Japan,Asia Pacific
1
dbt seed  # Loads CSV files as tables in the warehouse

Reference them like any model:

1
2
3
SELECT o.*, cc.region
FROM {{ ref('stg_postgres__orders') }} o
LEFT JOIN {{ ref('country_codes') }} cc ON o.country_code = cc.country_code

Running dbt in Production

Basic CI with GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# .github/workflows/dbt-ci.yml
name: dbt CI

on:
  pull_request:
    paths:
      - 'models/**'
      - 'tests/**'
      - 'macros/**'

jobs:
  dbt-ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - name: Install dbt
        run: pip install dbt-core dbt-postgres

      - name: Install packages
        run: dbt deps

      - name: Check source freshness
        run: dbt source freshness
        env:
          DBT_PASSWORD: ${{ secrets.DBT_CI_PASSWORD }}

      - name: Run modified models + downstream
        run: |
          # Run only models changed in this PR plus their dependencies
          dbt run --select state:modified+ \
            --defer \
            --state ./target
        env:
          DBT_PASSWORD: ${{ secrets.DBT_CI_PASSWORD }}

      - name: Test modified models + downstream
        run: |
          dbt test --select state:modified+
        env:
          DBT_PASSWORD: ${{ secrets.DBT_CI_PASSWORD }}

The state:modified+ selector is the dbt equivalent of running only affected tests — it runs models changed in the PR plus everything downstream. This requires a manifest from the last production run (stored as an artifact or in object storage).

Production Orchestration with Airflow

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# dags/dbt_production.py
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.sensors.external_task import ExternalTaskSensor

DBT_DIR = "/opt/airflow/dbt/analytics"
DBT_PROFILE = "analytics"
DBT_TARGET = "prod"

default_args = {
    "owner": "data-engineering",
    "retries": 2,
    "retry_delay": timedelta(minutes=5),
    "on_failure_callback": slack_alert,   # Your alert function
}

with DAG(
    dag_id="dbt_production",
    default_args=default_args,
    schedule_interval="0 */6 * * *",   # Every 6 hours
    start_date=datetime(2024, 1, 1),
    catchup=False,
    tags=["dbt", "production"],
) as dag:

    # Wait for data loaders to finish
    wait_for_fivetran = ExternalTaskSensor(
        task_id="wait_for_fivetran",
        external_dag_id="fivetran_sync",
        external_task_id="fivetran_complete",
        timeout=3600,
        poke_interval=60,
    )

    check_sources = BashOperator(
        task_id="check_source_freshness",
        bash_command=f"""
            cd {DBT_DIR}
            dbt source freshness \
              --profiles-dir ~/.dbt \
              --profile {DBT_PROFILE} \
              --target {DBT_TARGET}
        """,
    )

    run_staging = BashOperator(
        task_id="run_staging_models",
        bash_command=f"""
            cd {DBT_DIR}
            dbt run \
              --select staging \
              --profiles-dir ~/.dbt \
              --profile {DBT_PROFILE} \
              --target {DBT_TARGET} \
              --threads 8
        """,
    )

    test_staging = BashOperator(
        task_id="test_staging_models",
        bash_command=f"""
            cd {DBT_DIR}
            dbt test \
              --select staging \
              --profiles-dir ~/.dbt \
              --profile {DBT_PROFILE} \
              --target {DBT_TARGET}
        """,
    )

    run_marts = BashOperator(
        task_id="run_mart_models",
        bash_command=f"""
            cd {DBT_DIR}
            dbt run \
              --select marts \
              --profiles-dir ~/.dbt \
              --profile {DBT_PROFILE} \
              --target {DBT_TARGET} \
              --threads 8
        """,
    )

    test_marts = BashOperator(
        task_id="test_mart_models",
        bash_command=f"""
            cd {DBT_DIR}
            dbt test \
              --select marts \
              --threads 4
        """,
    )

    generate_docs = BashOperator(
        task_id="generate_docs",
        bash_command=f"""
            cd {DBT_DIR}
            dbt docs generate
            aws s3 sync ./target s3://company-dbt-docs/latest/
        """,
    )

    # DAG order
    (
        wait_for_fivetran
        >> check_sources
        >> run_staging
        >> test_staging
        >> run_marts
        >> test_marts
        >> generate_docs
    )

The dbt CLI Cheat Sheet

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# ── Running Models ──────────────────────────────────────────────────────────

# Run all models
dbt run

# Run a specific model
dbt run --select dim_customers

# Run a model and all its dependencies (upstream)
dbt run --select +dim_customers

# Run a model and all models that depend on it (downstream)
dbt run --select dim_customers+

# Run a model and everything: upstream and downstream
dbt run --select +dim_customers+

# Run only staging models
dbt run --select staging

# Run everything in a directory
dbt run --select models/marts/finance

# Run models changed since last run (great for CI)
dbt run --select state:modified+

# Full refresh of incremental models
dbt run --select fct_events --full-refresh

# ── Testing ─────────────────────────────────────────────────────────────────

dbt test                          # All tests
dbt test --select dim_customers   # Tests for one model
dbt test --select +dim_customers  # Tests for model + upstream
dbt test --select "tag:critical"  # Tests tagged as critical

# ── Other Commands ───────────────────────────────────────────────────────────

dbt compile                       # Compile SQL without running
dbt source freshness              # Check source table age
dbt docs generate && dbt docs serve  # Build + open documentation
dbt snapshot                      # Run snapshot models
dbt seed                          # Load CSV seeds
dbt deps                          # Install packages
dbt clean                         # Delete target/ and dbt_packages/
dbt debug                         # Test connection and configuration
dbt list                          # List all models in project
dbt list --select +dim_customers  # List models in the DAG subtree

Common Patterns and Anti-Patterns

Patterns That Work

Use ref() everywhere — never hardcode a schema or table name in a model. If you hardcode raw_data.stripe.payments, your CI environment will fail because it points at production data.

Keep staging models thin — one source table in, renamed and cast columns out. No joins. This makes debugging vastly easier: if something is wrong in a mart, you know the staging layer is clean.

Tag critical models for targeted testing:

1
2
3
4
models:
  - name: fct_revenue
    config:
      tags: ['critical', 'finance']
1
dbt test --select "tag:critical"  # Run critical tests first

Use dbt_utils.generate_surrogate_key() for surrogate keys on joined tables:

1
2
3
4
5
6
SELECT
    {{ dbt_utils.generate_surrogate_key(['order_id', 'product_id']) }} AS order_line_key,
    order_id,
    product_id,
    quantity
FROM {{ ref('stg_postgres__order_lines') }}

Anti-Patterns to Avoid

Don’t join in staging — staging is one source, one table. Joining in staging defeats the purpose of the layer hierarchy.

Don’t use SELECT * in marts — be explicit about columns. When the source table adds a column, your mart should consciously decide whether to include it.

Don’t put business logic in sources{{ source('stripe', 'payments') }} should reference the raw table directly. Business rules belong in staging and intermediate.

Don’t skip tests — a model without tests is technical debt. At minimum, every primary key should have unique and not_null tests.

Conclusion

dbt turns your data warehouse transformations from a folder of ad-hoc SQL scripts into a maintainable, tested, documented software project. The staging → intermediate → mart architecture scales from a five-table startup to a thousand-model enterprise warehouse without becoming unmaintainable. Tests catch data quality regressions before they reach your dashboards. Documentation auto-generates from the YAML you write anyway. The lineage graph answers “where does this number come from?” in seconds.

The learning curve is shallow — if you know SQL and basic Python tooling, you are productive in dbt within a day. The return on investment compounds: every model you add to the DAG benefits from the same testing, documentation, and CI infrastructure you set up for the first one.

Start with your most painful SQL transformation — the one that three people have their own version of and nobody fully understands. Model it in dbt with proper staging, a schema test or two, and a column description. The act of doing it once makes the value of doing it everywhere obvious.

Comments