EVIKA ACADEMY · DATA ANALYTICS SERIES · CHAPTER 90

dbt for Data Analysts

Models, materialisation strategies, sources, refs, schema tests, incremental models, documentation, and lineage DAG — the analytics engineering toolkit that turns SQL into production data infrastructure.

⏱ 22 min read🔧 dbt · Analytics Engineering · SQL
← Ch 89: Data Warehouse FundamentalsCh 91: Presenting Insights →

For most of analytics history, SQL transformations lived in one-off scripts, stored procedures, or BI tool calculated fields — undocumented, untested, and impossible to trace. When the same metric appeared differently in two dashboards, nobody could explain why. dbt solves this by treating SQL transformations the way software engineers treat code: version-controlled, modular, tested, documented, and automatically orchestrated.

You do not need to be an engineer to use dbt. If you can write a SELECT statement, you can write a dbt model. This chapter covers the concepts and patterns that make dbt valuable for analysts working with modern cloud warehouses like BigQuery, Snowflake, or Redshift.

What dbt Does (and Doesn't Do)

dbt handles
✓ Compiling SELECT models into CREATE TABLE/VIEW DDL
✓ Running models in dependency order (DAG)
✓ Running tests against model output
✓ Generating a documentation site with lineage
✓ Source freshness checks
✓ Incremental loads (append/merge new rows only)
dbt does NOT handle
✗ Extracting data from source systems (that's EL tools: Fivetran, Airbyte)
✗ Loading data into the warehouse
✗ Running Python transformations (dbt-core supports Python models, but SQL is the default)
✗ Scheduling runs (that's Airflow, Prefect, or dbt Cloud's scheduler)

Project Structure

your_dbt_project/
├── dbt_project.yml          # project config: name, profile, model paths
├── profiles.yml             # warehouse connection settings (usually ~/.dbt/)
├── models/
│   ├── staging/             # 1-to-1 with source tables: rename, cast, light cleaning
│   │   ├── _staging.yml     # source definitions + staging model docs/tests
│   │   ├── stg_orders.sql
│   │   ├── stg_customers.sql
│   │   └── stg_products.sql
│   ├── intermediate/        # business logic: joins, calculations, aggregations
│   │   ├── int_order_items_enriched.sql
│   │   └── int_customer_lifetime_value.sql
│   └── marts/               # final consumption layer for BI tools / analysts
│       ├── core/
│       │   ├── fct_orders.sql
│       │   └── dim_customers.sql
│       └── marketing/
│           └── mkt_campaign_performance.sql
├── tests/                   # custom (singular) tests
│   └── assert_no_negative_revenue.sql
├── macros/                  # reusable Jinja SQL snippets
│   └── cents_to_dollars.sql
└── seeds/                   # static CSV files loaded as tables
    └── country_codes.csv

Models: Writing Your First dbt SQL

A dbt model is a .sql file containing a SELECT statement. Nothing else. dbt wraps it in the appropriate DDL and executes it. You reference other models with {{ ref() }} — this is how dbt knows the dependency order.

-- models/staging/stg_orders.sql
-- Staging layer: rename columns, cast types, no business logic yet

SELECT
    id                                      AS order_id,
    customer_id,
    created_at                              AS order_created_at,
    CAST(total_amount_cents AS NUMERIC)
        / 100                               AS order_total,
    LOWER(status)                           AS order_status,
    COALESCE(discount_code, 'none')         AS discount_code,
    DATE(created_at)                        AS order_date
FROM {{ source('raw', 'orders') }}          -- references a declared source
WHERE id IS NOT NULL


-- models/intermediate/int_order_items_enriched.sql
-- Intermediate: join staging models, add calculated fields

SELECT
    o.order_id,
    o.order_date,
    o.order_status,
    c.customer_name,
    c.region,
    p.product_name,
    p.category,
    oi.quantity,
    oi.unit_price,
    oi.quantity * oi.unit_price             AS line_revenue,
    p.cost_price,
    (oi.unit_price - p.cost_price)
        * oi.quantity                       AS line_margin
FROM {{ ref('stg_orders') }}        o
JOIN {{ ref('stg_order_items') }}   oi  USING (order_id)
JOIN {{ ref('stg_customers') }}     c   ON o.customer_id = c.customer_id
JOIN {{ ref('stg_products') }}      p   ON oi.product_id = p.product_id


-- models/marts/core/fct_orders.sql
-- Mart: final aggregated fact table consumed by BI tools

SELECT
    order_id,
    order_date,
    customer_name,
    region,
    category,
    SUM(line_revenue)                       AS total_revenue,
    SUM(line_margin)                        AS total_margin,
    COUNT(DISTINCT product_name)            AS distinct_products,
    SUM(quantity)                           AS total_units
FROM {{ ref('int_order_items_enriched') }}
GROUP BY 1, 2, 3, 4, 5

Materialisation Strategies

view
Default. dbt creates a SQL view. No data stored — query runs live each time. Best for staging models that are fast to compute.
Best for: Staging layer
table
dbt drops and recreates the table on every run. Slower to build but faster to query. Best for complex transforms queried frequently.
Best for: Intermediate & mart layers
incremental
First run creates the table; subsequent runs only process new/changed rows. Dramatically faster for large fact tables.
Best for: Large fact tables with append-only or updatable rows
ephemeral
Not materialised in the warehouse at all — compiled into a CTE and inlined wherever referenced. No table or view created.
Best for: Small helper transforms used in one model only
-- Set materialisation in dbt_project.yml (applies to all models in a folder):
models:
  your_project:
    staging:
      +materialized: view
    intermediate:
      +materialized: view
    marts:
      +materialized: table

-- Or override per model with a config block at the top of the .sql file:
{{ config(materialized='table') }}

SELECT ...


-- Incremental model pattern:
{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge'   -- or 'insert_overwrite', 'append'
) }}

SELECT
    order_id,
    order_date,
    customer_id,
    total_revenue
FROM {{ source('raw', 'orders') }}

{% if is_incremental() %}
    -- Only process rows newer than the latest row already in the table
    WHERE order_created_at > (SELECT MAX(order_date) FROM {{ this }})
{% endif %}

Sources and the ref() Function

-- Declare sources in a _staging.yml file:
# models/staging/_staging.yml

version: 2

sources:
  - name: raw                              # logical name for this source
    database: your_project                 # BigQuery project / Snowflake DB
    schema: raw_data                       # schema where raw tables live
    loaded_at_field: _etl_loaded_at        # for freshness checks
    freshness:
      warn_after: {count: 12, period: hour}
      error_after: {count: 24, period: hour}

    tables:
      - name: orders
        description: "Raw orders from the transactional database"
      - name: customers
      - name: products

models:
  - name: stg_orders
    description: "Cleaned and renamed orders from raw source"
    columns:
      - name: order_id
        description: "Primary key"
        tests:
          - unique
          - not_null
      - name: order_status
        tests:
          - accepted_values:
              values: ['pending', 'processing', 'completed', 'cancelled', 'refunded']
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('stg_customers')
              field: customer_id

Testing Your Models

dbt has two types of tests. Schema tests (also called generic tests) are declared in YAML and run against column values. Singular tests are custom SQL files that should return zero rows if the test passes.

-- Generic tests — declared in schema YAML (see above)
-- Run with: dbt test

-- Four built-in generic tests:
-- unique        : no duplicate values in this column
-- not_null      : no NULL values
-- accepted_values: all values in a defined list
-- relationships : foreign key integrity check


-- Singular test: custom SQL that should return 0 rows if test passes
-- tests/assert_no_negative_revenue.sql

SELECT
    order_id,
    total_revenue
FROM {{ ref('fct_orders') }}
WHERE total_revenue < 0

-- If this query returns any rows, the test FAILS


-- Useful dbt commands:
-- dbt run              → compile and run all models
-- dbt test             → run all tests
-- dbt run --select stg_orders   → run one model
-- dbt test --select fct_orders  → test one model
-- dbt build            → run + test together
-- dbt source freshness → check when sources were last loaded
-- dbt docs generate    → build documentation site
-- dbt docs serve       → open docs in browser with lineage DAG

Macros: Reusable SQL Logic

-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name) %}
    ({{ column_name }} / 100.0)
{% endmacro %}

-- Use in any model:
SELECT
    order_id,
    {{ cents_to_dollars('total_amount_cents') }} AS order_total
FROM {{ source('raw', 'orders') }}


-- macros/generate_surrogate_key.sql
-- dbt_utils package provides this — install via packages.yml:
-- packages:
--   - package: dbt-labs/dbt_utils
--     version: [">=1.0.0", "<2.0.0"]

-- Then use:
SELECT
    {{ dbt_utils.generate_surrogate_key(['order_id', 'product_id']) }} AS order_line_key,
    order_id,
    product_id
FROM {{ ref('stg_order_items') }}

The Lineage DAG: dbt's Most Valuable Feature

Run dbt docs generate && dbt docs serve and dbt opens a browser with a visual directed acyclic graph (DAG) of every model and its dependencies. You can click any model to see its SQL, description, columns, test results, and which models feed into it and which it feeds downstream.

This lineage view solves the most persistent problem in analytics: when a metric changes unexpectedly, you can trace exactly which upstream model caused it, which source table fed that, and which downstream marts are affected. No more guessing where a number came from.

Sample lineage (left → right = upstream → downstream)
source.raw.orders → stg_orders → int_order_items_enriched → fct_orders
source.raw.customers → stg_customers → int_order_items_enriched
source.raw.products → stg_products → int_order_items_enriched
fct_orders → Power BI dashboard
fct_orders → Looker Studio report

dbt vs Raw SQL: When to Use Each

ScenarioUse raw SQLUse dbt
One-off analysis✓ Faster to write, no overhead✗ Overkill
Shared metric definitions✗ Each person reinvents the JOIN✓ One model, everyone refs it
Production dashboards✗ No tests, hard to trace✓ Tested, documented, versioned
Incremental ETL✗ Complex to manage manually✓ Built-in incremental materialisation
Team of 3+ analysts✗ Diverging logic over time✓ Shared codebase in Git
Data quality monitoring✗ Manual spot-checks✓ dbt test runs on every build
← Ch 89: Data Warehouse FundamentalsCh 91: Presenting Insights →
Learn Analytics Engineering at EVIKA ACADEMY
Sector 51, Noida · Live classes online & offline
Book Free DemoWhatsApp Us
🎓 Free Demo Class — Online & Offline · Noida Sector 51