Data Engineering
Hands-onIntermediate 40 min· Pipelines · 9 of 18

Transformations with dbt

Build a small dbt project on DuckDB: staging and mart models joined with ref(), tests for bad data, generated docs and lineage. Learn why dbt became the standard way to do the T in ELT, and when to use views, tables or incremental models.

What you will build: a small dbt project on your laptop that turns raw Brew & Bean order data into clean staging models and a daily_revenue table, with tests and documentation. It runs on DuckDB, a free database that lives in a single file, so you need no cloud account.

Prerequisites: SQL (joins, GROUP BY, CTEs from *Advanced SQL*), Python 3.9 or newer, and the ELT idea from *ETL vs ELT and pipeline design*.

What dbt does

In ELT the raw data is loaded into the warehouse first and transformed there with SQL. That leaves a question: where does all that SQL live? Often in scattered scripts and dashboard queries that nobody tests, and that break when a column changes.

dbt (data build tool) is the T in ELT. You write each transformation as a SELECT statement in its own .sql file called a model. dbt works out the order the models must run in, wraps each SELECT in the right CREATE VIEW or CREATE TABLE statement, runs them in your warehouse, and lets you add tests and documentation next to the code. Because it is plain text files, everything goes in Git, gets reviewed in pull requests and runs in CI.

dbt does not extract or load data and does not process data itself; the warehouse does the work. dbt Core is open source and free. dbt also has a hosted product (dbt Cloud); check the official site for its current plans.

dbt vocabulary

IdeaMeaning
ModelA .sql file containing one SELECT; becomes a view or table
Source / seedRaw input. A source is a table already in the warehouse; a seed is a small CSV that dbt loads for you
ref()How one model refers to another, e.g. {{ ref('stg_orders') }}. dbt builds the dependency graph from these
MaterializationHow a model is stored: view, table, incremental or ephemeral
TestA check on your data, such as unique, not_null or a custom query
AdapterThe plug-in for your warehouse: dbt-duckdb, dbt-bigquery, dbt-snowflake, dbt-postgres and others

Set up the project

  1. 1Create a folder brew_bean and open a terminal in it.
  2. 2Create and activate a virtual environment (python -m venv .venv, then activate it as in the Spark lesson).
  3. 3Install dbt with the DuckDB adapter: pip install dbt-duckdb. This also installs dbt Core.
  4. 4Check it works: dbt --version.
  5. 5Create the folders models/staging, models/marts and seeds, then add the files below.
dbt_project.yml
name: brew_bean
version: "1.0.0"
profile: brew_bean

model-paths: ["models"]
seed-paths: ["seeds"]

models:
  brew_bean:
    staging:
      +materialized: view
    marts:
      +materialized: table

Project settings. Staging models become views; marts become tables. The profile name must match profiles.yml.

profiles.yml
brew_bean:
  target: dev
  outputs:
    dev:
      type: duckdb
      path: brew.duckdb
      threads: 1

Connection details. dbt looks for profiles.yml in the current folder first, then in ~/.dbt. For a real warehouse this file holds credentials, so never commit it to Git.

Now the raw data. Seeds are CSV files in the seeds folder. They suit small reference data and tutorials; real order data would be loaded by an ingestion tool and declared as a source.

seeds/raw_orders.csv
order_id,customer_name,item_id,qty,created_at
1,Asha,cappuccino,2,2026-10-01 09:15:00
2,Ravi,espresso,1,2026-10-01 09:40:00
3,Meera,croissant,2,2026-10-01 18:05:00
4,Asha,masala-chai,1,2026-10-02 08:30:00
5,Kiran,cold-brew,1,2026-10-02 17:45:00
6,Ravi,veg-sandwich,1,2026-10-02 19:10:00
7,Meera,filter-coffee,3,2026-10-03 08:55:00
8,Asha,banana-bread,2,2026-10-03 16:20:00

Raw orders, one line per order for simplicity.

seeds/raw_menu.csv
item_id,name,category,price
espresso,Espresso,coffee,120
cappuccino,Cappuccino,coffee,180
cold-brew,Cold Brew,coffee,220
filter-coffee,Filter Coffee,coffee,80
masala-chai,Masala Chai,tea,90
croissant,Butter Croissant,bakery,150
banana-bread,Banana Bread,bakery,130
veg-sandwich,Veg Grilled Sandwich,food,160

The Brew & Bean menu.

Write the models

A common layout has two layers. Staging models sit directly on raw data and only clean it: rename columns, cast types, one staging model per raw table. Marts models join and aggregate staged data into tables that analysts and dashboards use. Each layer refers to the previous one with ref().

models/staging/stg_orders.sql
select
    order_id,
    customer_name,
    item_id,
    qty,
    cast(created_at as timestamp) as created_at
from {{ ref('raw_orders') }}

Cleans raw_orders. {{ ref('raw_orders') }} points at the seed and tells dbt to build the seed first.

models/staging/stg_menu.sql
select
    item_id,
    name as item_name,
    category,
    price
from {{ ref('raw_menu') }}

Renames name to item_name so it is clearer in joins.

models/marts/daily_revenue.sql
with lines as (
    select
        cast(o.created_at as date) as order_date,
        o.order_id,
        o.qty * m.price as revenue
    from {{ ref('stg_orders') }} as o
    join {{ ref('stg_menu') }} as m
        on o.item_id = m.item_id
)

select
    order_date,
    count(distinct order_id) as orders,
    sum(revenue) as revenue
from lines
group by order_date
order by order_date

The daily revenue table. Notice there is no CREATE TABLE: dbt adds that based on the materialization setting.

Add tests and docs

Tests are declared in YAML next to the models. Built-in generic tests are unique, not_null, accepted_values and relationships (a foreign-key check). dbt runs each as a query that looks for bad rows; a test passes when it finds none.

models/schema.yml
version: 2

models:
  - name: stg_orders
    description: "One row per order line, cleaned from raw_orders."
    columns:
      - name: order_id
        description: "Unique id of the order."
        data_tests:
          - unique
          - not_null
      - name: item_id
        data_tests:
          - not_null
          - relationships:
              to: ref('stg_menu')
              field: item_id
      - name: qty
        data_tests:
          - not_null

  - name: stg_menu
    columns:
      - name: item_id
        data_tests:
          - unique
          - not_null
      - name: category
        data_tests:
          - accepted_values:
              values: ["coffee", "tea", "bakery", "food"]

  - name: daily_revenue
    description: "Orders and revenue in rupees for each day."
    columns:
      - name: order_date
        data_tests:
          - unique
          - not_null

Descriptions feed the generated docs; data_tests run with dbt test. In dbt versions before 1.8 the key is called tests instead of data_tests. Newer dbt versions may print a deprecation warning suggesting that arguments like values: and to: go under an arguments: key; the file still works, and the warning tells you how to update it.

Build and check

Terminal
dbt debug      # checks your project and connection
dbt seed       # loads the CSVs into DuckDB
dbt run        # builds the models in dependency order
dbt test       # runs all tests
dbt build      # seed + run + test together, in order, stopping dependents on failure

Run these from the brew_bean folder. Use dbt build in day-to-day work.

What a successful run looks like (abridged)
1 of 8 START seed file main.raw_menu ........ [INSERT 8 in 0.1s]
...
Done. PASS=15 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=15

To look at the result, open the DuckDB file from Python (install the library with pip install duckdb if needed):

check.py
import duckdb

con = duckdb.connect("brew.duckdb", read_only=True)
print(con.sql("select * from daily_revenue").fetchall())

Reads the table dbt built. Because the data matches the Spark lesson, you should see the same totals.

Expected result
[(datetime.date(2026, 10, 1), 3, 780), (datetime.date(2026, 10, 2), 3, 470), (datetime.date(2026, 10, 3), 2, 500)]

Now generate documentation and a lineage graph that shows how models depend on each other:

Terminal
dbt docs generate
dbt docs serve

Opens a local website with every model, its columns, descriptions, tests and a lineage diagram.

Break a test on purpose

Open seeds/raw_orders.csv and change espresso in row 2 to espresso2. Run dbt seed then dbt test. The relationships test on stg_orders.item_id fails because that item is not in the menu. dbt shows how many rows failed and the file with the compiled test query. This is the real value of dbt: bad data is caught at the pipeline, not by an angry stakeholder looking at a wrong dashboard. Undo your edit and run dbt build again.

Materializations and incremental models

Choosing a materialization

MaterializationWhat it createsUse when
viewA saved query, computed every time it is readLight transformations; staging
tableA physical table rebuilt on each runMarts that are read often; small to medium data
incrementalA table where only new or changed rows are addedLarge event tables where a full rebuild is slow
ephemeralNot created; inlined as a CTETiny reusable steps

An incremental model adds a filter that applies only on later runs. The sketch below shows the idea; it needs a real source with a growing table, so treat it as a pattern to adapt.

models/marts/fct_order_lines.sql (pattern, not part of the project)
{{ config(materialized='incremental', unique_key='order_id') }}

select order_id, item_id, qty, created_at
from {{ ref('stg_orders') }}

{% if is_incremental() %}
where created_at > (select max(created_at) from {{ this }})
{% endif %}

On the first run dbt builds the whole table. After that, only rows newer than the latest existing row are processed, and unique_key lets dbt replace rows that changed. {{ this }} means the table being built.

Incremental is faster, not free

Incremental models can drift from the truth if late-arriving or updated rows are missed by the filter. Many teams do a full refresh (dbt run --full-refresh) on a schedule and test the result against the source.

Using dbt with other tools

In production dbt is usually run by an orchestrator (see *Orchestration with Apache Airflow*), against a warehouse such as BigQuery, Snowflake or Databricks (see *Cloud data platforms*). You switch warehouses by installing a different adapter and changing profiles.yml; most of your SQL carries over. The data quality ideas here are extended in *Data quality, testing and observability*.

How this comes up in interviews: what dbt does and does not do; ref() and the DAG; the difference between view, table and incremental; how you test data; and why ELT with dbt replaced many hand-written SQL scripts.

Prompt · Write a dbt model
You are an analytics engineer. Write a dbt model in SQL called [model_name] that [what it should produce, e.g. shows revenue per category per month]. Use ref() to refer to these staging models: [list of models and their columns]. Then write the schema.yml entries with descriptions and sensible tests (unique, not_null, accepted_values, relationships). Explain each test in one sentence.

What to expect: Check that the model uses ref() instead of hard-coded table names, groups by every non-aggregated column, and that the tests make sense for the grain (what one row means).

Try it: Add a category model

Create models/marts/category_revenue.sql that shows total quantity and revenue per category. Add a unique and not_null test on category. Run dbt build and compare the totals with the Spark lesson (coffee 940, bakery 560, food 160, tea 90).

Key takeaways

  1. dbt is the T in ELT: you write SELECT statements as models and dbt builds them in the warehouse in the right order.
  2. ref() links models and lets dbt build the dependency graph; staging models clean raw data and marts models serve analysts.
  3. Tests (unique, not_null, accepted_values, relationships) catch bad data at pipeline time; dbt build runs seed, run and test together.
  4. Pick view, table or incremental based on size and read frequency; incremental needs care with late data.
  5. Everything is text in Git, so transformations can be reviewed, tested and run in CI.

Check yourself

Finishing the quiz marks the lesson as done.

1. What does dbt actually do when you run dbt run?

2. In a model you write from {{ ref('stg_orders') }}. What is the main benefit over writing the table name directly?

3. A relationships test on stg_orders.item_id fails. What does that mean?

4. Which materialization suits a huge, append-mostly events table where a full rebuild takes hours?

5. Why is profiles.yml usually kept out of Git?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free