Guide, 12 min read, updated 30 September 2026

Your first dbt project: models, tests and documentation, step by step

How analytics engineers turn SQL scripts into tested, documented, version-controlled pipelines.

dbtSQLData modelling
All guides and cheat sheets

dbt (data build tool) lets you write data transformations as plain SELECT statements, and handles the rest: creating tables and views in the right order, testing the results, and generating documentation. It brings software engineering habits, such as version control, code review and automated testing, to analytics. It is one of the defining tools of analytics engineering.

How dbt thinks

Each model is a .sql file containing one SELECT. dbt wraps it in the right CREATE TABLE or CREATE VIEW statement for your warehouse. Models reference each other with ref(), which is how dbt builds a dependency graph and runs everything in the correct order.

Setting up

Terminal
# Install dbt Core with the adapter for your warehouse, for example Snowflake
python -m pip install dbt-core dbt-snowflake

dbt init shop_analytics      # creates the project and asks for connection details
cd shop_analytics
dbt debug                    # checks the connection works

A clean, conventional structure makes projects easy to navigate:

Text
models/
  staging/
    shop/
      _shop__sources.yml
      stg_shop__orders.sql
      stg_shop__customers.sql
  marts/
    fct_orders.sql
    dim_customers.sql
    _marts__models.yml

Declare your sources

Sources describe the raw tables dbt reads from. Declaring them once means you can test them and track freshness.

YAML
version: 2

sources:
  - name: shop
    database: analytics
    schema: raw
    tables:
      - name: orders
        loaded_at_field: _loaded_at
        freshness:
          warn_after: {count: 12, period: hour}
          error_after: {count: 24, period: hour}
      - name: customers

Staging models: clean, rename, cast

One staging model per source table. Keep them simple: rename columns consistently, cast types, and nothing else.

SQL
-- models/staging/shop/stg_shop__orders.sql
with source as (
    select * from {{ source('shop', 'orders') }}
),

renamed as (
    select
        order_id,
        customer_id,
        cast(order_date as date)          as order_date,
        cast(amount as numeric(10, 2))    as order_amount,
        lower(status)                     as order_status
    from source
)

select * from renamed

Mart models: business logic

Marts combine staging models into the facts and dimensions people report on. Notice ref() instead of hard-coded table names.

SQL
-- models/marts/dim_customers.sql
with customers as (
    select * from {{ ref('stg_shop__customers') }}
),

orders as (
    select * from {{ ref('stg_shop__orders') }}
    where order_status = 'completed'
),

customer_orders as (
    select
        customer_id,
        min(order_date)   as first_order_date,
        max(order_date)   as last_order_date,
        count(*)          as lifetime_orders,
        sum(order_amount) as lifetime_value
    from orders
    group by customer_id
)

select
    c.customer_id,
    c.customer_name,
    co.first_order_date,
    co.last_order_date,
    coalesce(co.lifetime_orders, 0) as lifetime_orders,
    coalesce(co.lifetime_value, 0)  as lifetime_value
from customers c
left join customer_orders co on co.customer_id = c.customer_id

Tests: prove your data is right

Generic data tests are declared in YAML. dbt turns each one into a query that returns failing rows.

YAML
version: 2

models:
  - name: dim_customers
    description: One row per customer, with lifetime order metrics.
    columns:
      - name: customer_id
        description: Primary key.
        data_tests:
          - unique
          - not_null
      - name: lifetime_orders
        data_tests:
          - not_null

  - name: fct_orders
    columns:
      - name: customer_id
        data_tests:
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: order_status
        data_tests:
          - accepted_values:
              values: ['completed', 'refunded', 'cancelled']

Recent versions of dbt use the key data_tests:. Older projects use tests:, which still works in many versions.

Run everything

Terminal
dbt build                          # run models, tests, seeds and snapshots in dependency order
dbt build --select dim_customers+  # one model and everything downstream of it
dbt source freshness               # check raw data is up to date
dbt docs generate && dbt docs serve  # browse documentation and the lineage graph

Incremental models for large tables

Rebuilding a billion-row table every day is wasteful. An incremental model only processes new rows.

SQL
{{ config(materialized='incremental', unique_key='event_id') }}

select event_id, user_id, event_type, event_timestamp
from {{ source('app', 'events') }}

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

Snapshots: history for changing records

Snapshots record how rows change over time, implementing the Type 2 slowly changing dimensions described in the star schema guide.

SQL
{% snapshot customers_snapshot %}
{{ config(
    target_schema='snapshots',
    unique_key='customer_id',
    strategy='timestamp',
    updated_at='updated_at'
) }}
select * from {{ source('shop', 'customers') }}
{% endsnapshot %}

Habits that make dbt projects great

Written by Alessandro Ecclesie Agazzi, freelance analytics engineer in London. Updated 30 September 2026.