Your First dbt Model
Set up a dbt project on DuckDB, load seed data, build a model with dbt run, and read the SQL dbt actually sent to the warehouse.
dbt turns a folder of SELECT statements into a dependency-ordered set of views and tables
in your warehouse. You write the query; dbt writes the create around it, works out what
must run first, and tells you what happened.
Installing
pip install dbt-core dbt-duckdb
dbt --version
Core:
- installed: 1.9.1
- latest: 1.9.1 - Up to date!
Plugins:
- duckdb: 1.9.1 - Up to date!
The adapter is the second package. Swap dbt-duckdb for dbt-snowflake or dbt-bigquery
later and the project below runs unchanged.
The project
A dbt project is two config files and a folder of SQL:
bookshop/
├── dbt_project.yml
├── profiles.yml
├── seeds/
│ ├── raw_customers.csv
│ └── raw_orders.csv
└── models/
└── staging/
└── stg_orders.sql
# dbt_project.yml
name: 'bookshop'
version: '1.0.0'
profile: 'bookshop'
model-paths: ['models']
seed-paths: ['seeds']
models:
bookshop:
staging:
+materialized: view
# profiles.yml
bookshop:
target: dev
outputs:
dev:
type: duckdb
path: bookshop.duckdb
threads: 4
dbt_project.yml describes the project; profiles.yml describes the connection. They are
separate on purpose — the project is committed to git, the profile holds credentials and
usually is not.
Check the connection before writing anything:
dbt debug
08:14:02 Running with dbt=1.9.1
08:14:02 dbt version: 1.9.1
08:14:02 python version: 3.11.9
08:14:02 Using profiles dir at /home/you/bookshop
08:14:02 Using dbt_project.yml file at /home/you/bookshop/dbt_project.yml
08:14:02 adapter type: duckdb
08:14:02 Configuration:
08:14:02 profiles.yml file [OK found and valid]
08:14:02 dbt_project.yml file [OK found and valid]
08:14:02 Required dependencies:
08:14:02 - git [OK found]
08:14:02 Connection test: [OK connection ok]
08:14:02 All checks passed!
dbt debug is the first thing to run when anything is broken. It separates “my SQL is
wrong” from “dbt cannot reach the warehouse”, which are very different afternoons.
Some data to transform
Seeds are CSV files dbt loads as tables. They are for small, static reference data — not for loading your production dataset.
# seeds/raw_customers.csv
id,first_name,last_name,country
1,Ada,Lovelace,GB
2,Grace,Hopper,US
3,Alan,Turing,GB
4,Katherine,Johnson,US
5,Edsger,Dijkstra,NL
# seeds/raw_orders.csv
id,customer_id,ordered_at,status,amount
1001,1,2026-01-04,completed,25.50
1002,2,2026-01-05,completed,12.00
1003,1,2026-01-07,returned,40.00
1004,3,2026-01-09,completed,8.75
1005,2,2026-01-11,pending,63.20
1006,9,2026-01-12,completed,19.99
dbt seed
08:16:44 Running with dbt=1.9.1
08:16:44 Registered adapter: duckdb=1.9.1
08:16:44 Found 2 seeds, 431 macros
08:16:44
08:16:44 Concurrency: 4 threads (target='dev')
08:16:44
08:16:44 1 of 2 START seed file main.raw_customers ...................... [RUN]
08:16:44 1 of 2 OK loaded seed file main.raw_customers .................. [INSERT 5 in 0.09s]
08:16:44 2 of 2 START seed file main.raw_orders ......................... [RUN]
08:16:44 2 of 2 OK loaded seed file main.raw_orders ..................... [INSERT 6 in 0.06s]
08:16:44
08:16:44 Finished running 2 seeds in 0 hours 0 minutes and 0.19 seconds (0.19s).
08:16:44
08:16:44 Completed successfully
08:16:44
08:16:44 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 TOTAL=2
Note the shape of that last line. PASS / WARN / ERROR / SKIP / TOTAL is how every dbt
command ends, and in CI it is the line worth grepping.
The first model
A model is a file containing one SELECT. The filename becomes the relation name.
-- models/staging/stg_orders.sql
select
id as order_id,
customer_id,
ordered_at::date as ordered_at,
status,
amount
from {{ source('raw', 'raw_orders') }}
where status != 'pending'
For now, point it straight at the seeded table so there is nothing else to explain:
-- models/staging/stg_orders.sql
select
id as order_id,
customer_id,
ordered_at::date as ordered_at,
status,
amount
from {{ ref('raw_orders') }}
where status != 'pending'
dbt run
08:19:31 Running with dbt=1.9.1
08:19:31 Registered adapter: duckdb=1.9.1
08:19:31 Found 1 model, 2 seeds, 431 macros
08:19:31
08:19:31 Concurrency: 4 threads (target='dev')
08:19:31
08:19:31 1 of 1 START sql view model main.stg_orders .................... [RUN]
08:19:31 1 of 1 OK created sql view model main.stg_orders ............... [OK in 0.04s]
08:19:31
08:19:31 Finished running 1 view model in 0 hours 0 minutes and 0.11 seconds (0.11s).
08:19:31
08:19:31 Completed successfully
08:19:31
08:19:31 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
“created sql view model” — dbt decided this was a view because dbt_project.yml said
+materialized: view for everything under staging/. Nothing in the SQL file said so.
What dbt actually ran
This is the part worth understanding early. dbt writes two versions of every model to
target/:
cat target/compiled/bookshop/models/staging/stg_orders.sql
select
id as order_id,
customer_id,
ordered_at::date as ordered_at,
status,
amount
from "bookshop"."main"."raw_orders"
where status != 'pending'
The {{ ref(...) }} has become a real table name. That is the compiled SQL — valid
on its own, and the thing to paste into a SQL editor when a model returns the wrong rows.
cat target/run/bookshop/models/staging/stg_orders.sql
create view "bookshop"."main"."stg_orders__dbt_tmp" as (
select
id as order_id,
customer_id,
ordered_at::date as ordered_at,
status,
amount
from "bookshop"."main"."raw_orders"
where status != 'pending'
);
That is the run SQL — your query wrapped in the DDL dbt generated. You never write this and you rarely read it, but knowing it exists explains what dbt is: a code generator with a dependency graph.
Seeing the result
duckdb bookshop.duckdb -c "select * from main.stg_orders order by order_id"
┌──────────┬─────────────┬────────────┬───────────┬────────┐
│ order_id │ customer_id │ ordered_at │ status │ amount │
│ int64 │ int64 │ date │ varchar │ double │
├──────────┼─────────────┼────────────┼───────────┼────────┤
│ 1001 │ 1 │ 2026-01-04 │ completed │ 25.5 │
│ 1002 │ 2 │ 2026-01-05 │ completed │ 12.0 │
│ 1003 │ 1 │ 2026-01-07 │ returned │ 40.0 │
│ 1004 │ 3 │ 2026-01-09 │ completed │ 8.75 │
│ 1006 │ 9 │ 2026-01-12 │ completed │ 19.99 │
└──────────┴─────────────┴────────────┴───────────┴────────┘
Five rows, not six: order 1005 was pending and the where clause dropped it. Order 1006
belongs to customer 9, who does not exist in raw_customers — a real referential problem
that lesson 4 catches automatically.
When a model fails
Break the SQL and run again:
select
id as order_id,
custmer_id, -- typo
amount
from {{ ref('raw_orders') }}
08:24:07 1 of 1 START sql view model main.stg_orders .................... [RUN]
08:24:07 1 of 1 ERROR creating sql view model main.stg_orders ........... [ERROR in 0.03s]
08:24:07
08:24:07 Finished running 1 view model in 0 hours 0 minutes and 0.09 seconds (0.09s).
08:24:07
08:24:07 Completed with 1 error and 0 warnings:
08:24:07
08:24:07 Runtime Error in model stg_orders (models/staging/stg_orders.sql)
08:24:07 Binder Error: Referenced column "custmer_id" not found in FROM clause!
08:24:07 Candidate bindings: "customer_id"
08:24:07
08:24:07 Done. PASS=0 WARN=0 ERROR=1 SKIP=0 TOTAL=1
The error is the warehouse’s own, with the model file named above it. dbt did not validate your SQL — it sent it to DuckDB and reported back. That is true of every adapter, which is why the same mistake gives you a different message on Snowflake.
Practice
1. Add a stg_customers model that splits the name fields and uppercases the country.
-- models/staging/stg_customers.sql
select
id as customer_id,
first_name,
last_name,
first_name || ' ' || last_name as full_name,
upper(country) as country_code
from {{ ref('raw_customers') }}
08:31:02 1 of 2 START sql view model main.stg_customers ................. [RUN]
08:31:02 1 of 2 OK created sql view model main.stg_customers ............ [OK in 0.04s]
08:31:02 2 of 2 START sql view model main.stg_orders .................... [RUN]
08:31:02 2 of 2 OK created sql view model main.stg_orders ............... [OK in 0.03s]
08:31:02 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 TOTAL=2
dbt found the new file with no registration step — the folder is the project. Renaming
stg_customers.sql renames the view on the next run.
2. Run only one model.
dbt run --select stg_orders
08:33:19 Found 2 models, 2 seeds, 431 macros
08:33:19
08:33:19 Concurrency: 4 threads (target='dev')
08:33:19
08:33:19 1 of 1 START sql view model main.stg_orders .................... [RUN]
08:33:19 1 of 1 OK created sql view model main.stg_orders ............... [OK in 0.04s]
08:33:19 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
“Found 2 models … 1 of 1” — it parsed everything and built one. --select is the flag you
will type most; lesson 2 covers the graph operators that make it powerful.
3. Change the staging materialization to table and rerun.
models:
bookshop:
staging:
+materialized: table
08:35:44 1 of 2 START sql table model main.stg_customers ................ [RUN]
08:35:44 1 of 2 OK created sql table model main.stg_customers ........... [OK in 0.06s]
08:35:44 2 of 2 START sql table model main.stg_orders ................... [RUN]
08:35:44 2 of 2 OK created sql table model main.stg_orders .............. [OK in 0.05s]
08:35:44 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 TOTAL=2
“sql table model” instead of “sql view model”, from a one-word config change and no edit to any SQL. That separation of logic from storage is most of dbt’s value; lesson 5 covers when each choice is right.
4. Delete target/ and run again. What is lost?
rm -rf target/ && dbt run
08:38:10 Found 2 models, 2 seeds, 431 macros
08:38:10 1 of 2 START sql table model main.stg_customers ................ [RUN]
...
08:38:10 Done. PASS=2 WARN=0 ERROR=0 SKIP=0 TOTAL=2
Nothing — target/ is regenerated output, which is why it belongs in .gitignore. The
artifacts inside it (manifest.json, run_results.json) are how dbt powers docs, state
comparison and Slim CI, all covered in lesson 10.
Next: ref and the DAG — how dbt works out what to build first.