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
# 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 worksA clean, conventional structure makes projects easy to navigate:
models/
staging/
shop/
_shop__sources.yml
stg_shop__orders.sql
stg_shop__customers.sql
marts/
fct_orders.sql
dim_customers.sql
_marts__models.ymlDeclare your sources
Sources describe the raw tables dbt reads from. Declaring them once means you can test them and track freshness.
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: customersStaging models: clean, rename, cast
One staging model per source table. Keep them simple: rename columns consistently, cast types, and nothing else.
-- 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 renamedMart models: business logic
Marts combine staging models into the facts and dimensions people report on. Notice ref() instead of hard-coded table names.
-- 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_idTests: prove your data is right
Generic data tests are declared in YAML. dbt turns each one into a query that returns failing rows.
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
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 graphIncremental models for large tables
Rebuilding a billion-row table every day is wasteful. An incremental model only processes new rows.
{{ 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.
{% 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
- Every model has a primary key with
uniqueandnot_nulltests. - Staging models never join; marts never read from sources directly.
- Descriptions are written as you build, not afterwards.
- Changes go through pull requests, with
dbt buildrunning in CI.
Written by Alessandro Ecclesie Agazzi, freelance analytics engineer in London. Updated 30 September 2026.