Skip to main content
dbt beginner Lesson 1 of 10

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.

Frequently Asked Questions

What does dbt actually do?
It takes a SELECT statement you write, wraps it in the right `create view` or `create table` DDL for your warehouse, and runs it in dependency order. dbt is a compiler and a scheduler for SQL — it never moves data itself, the warehouse does all the work.
Do I need Snowflake or BigQuery to learn dbt?
No. `dbt-duckdb` runs the whole thing against a local file, so a project builds in under a second with no account and no cost. Everything in this track except warehouse-specific tuning transfers unchanged to Snowflake, BigQuery, Redshift or Postgres.
What is the difference between dbt Core and dbt Cloud?
dbt Core is the open-source command-line tool that does the compiling and running. dbt Cloud is a hosted product that adds scheduling, a browser IDE, CI integration and hosted docs on top of it. Everything in this track is dbt Core.
Why write SELECT statements instead of CREATE TABLE?
Because the DDL, the drop-and-recreate logic and the ordering are mechanical, and dbt generates them from your configuration. You describe the shape of the data once, then switch a model from a view to a table by changing one line rather than rewriting it.