Data Engineering  /  dbt

๐Ÿ”„ dbt โ€” Data Build Tool 60 guides ยท updated 2026

Analytics engineering with SQL โ€” models, tests, sources, and Jinja macros that turn raw warehouse tables into trustworthy, documented data products.

Transforming Data with dbt: Patterns That Actually Work in Production

Raw data is useful in the same way that a pile of lumber is useful โ€” it has potential, but you cannot live in it yet. Data transformation is the process of turning that raw material into something analysts can actually use. dbt makes that process systematic, testable, and repeatable.

This article focuses on the practical side: what transformation patterns work, how they look in dbt, and where these patterns apply in real industries.


Why dbt Changed How Teams Do Transformation

Before dbt became mainstream, data transformation happened in a few fragile ways: stored procedures that nobody documented, Python scripts checked into a folder called scripts/, or complex ETL jobs in tools that only one person understood. Changes were risky because there were no tests. Debugging was hard because there was no lineage tracking.

dbt changed this by bringing software engineering discipline to SQL:

The result is a transformation layer that teams can actually maintain and improve over time.


The Three Layers of dbt Transformation

Most mature dbt projects use a three-layer architecture:

Layer 1: Staging Layer 2: Intermediate Layer 3: Marts
------------------ ------------------- -----------------
One model per source Cross-source joins Business-domain tables
Clean and rename Business logic Aggregations
Cast types Derived metrics Report-ready
No aggregation Intermediate calcs Used by BI tools

Not every project needs all three layers, but this pattern scales well and keeps each layer focused on one concern.


Example 1: Cleaning Customer Records

Raw customer data from CRMs is almost always messy โ€” inconsistent casing, whitespace in names, null emails, duplicates.

models/staging/stg_customers.sql:

with source as (
select * from {{ source('crm', 'customers') }}
),
cleaned as (
select
customer_id,
lower(trim(email)) as email,
initcap(trim(first_name)) as first_name,
initcap(trim(last_name)) as last_name,
initcap(trim(first_name))
|| ' ' || initcap(trim(last_name)) as full_name,
cast(created_at as date) as signup_date,
coalesce(lower(trim(status)), 'unknown') as customer_status
from source
where customer_id is not null
and email is not null
and email like '%@%'
),
deduplicated as (
select *,
row_number() over (
partition by email
order by signup_date asc
) as row_num
from cleaned
)
select * from deduplicated
where row_num = 1

This model cleans casing, filters invalid emails, and keeps only the earliest record per email address if duplicates exist.


Example 2: Monthly Revenue Aggregation

Finance teams need revenue aggregated by month, but they usually also need it broken out by product line or region.

models/marts/finance/fct_monthly_revenue.sql:

with orders as (
select * from {{ ref('stg_orders') }}
where status = 'completed'
),
products as (
select * from {{ ref('stg_products') }}
),
joined as (
select
o.order_id,
o.order_date,
o.customer_id,
o.order_amount_usd,
p.product_category,
p.product_line
from orders o
left join products p on o.product_id = p.product_id
),
monthly as (
select
date_trunc('month', order_date) as revenue_month,
product_category,
product_line,
count(distinct order_id) as order_count,
count(distinct customer_id) as unique_customers,
sum(order_amount_usd) as total_revenue_usd,
avg(order_amount_usd) as avg_order_value_usd
from joined
group by 1, 2, 3
)
select * from monthly
order by revenue_month, product_category

The finance team can now slice revenue by any dimension without maintaining separate queries for each one.


Example 3: Marketing Channel Attribution

Marketing teams want to know which channels drive conversions. This requires linking touchpoints (ad clicks, email opens, social interactions) to purchase events.

models/marts/marketing/fct_customer_journey.sql:

with touchpoints as (
select * from {{ ref('stg_marketing_touchpoints') }}
),
conversions as (
select
customer_id,
min(order_date) as first_purchase_date
from {{ ref('stg_orders') }}
where status = 'completed'
group by 1
),
pre_purchase_touchpoints as (
select
t.customer_id,
t.channel,
t.event_time,
t.campaign_name,
c.first_purchase_date,
datediff('day', t.event_time, c.first_purchase_date) as days_before_purchase
from touchpoints t
inner join conversions c
on t.customer_id = c.customer_id
where t.event_time <= c.first_purchase_date
and datediff('day', t.event_time, c.first_purchase_date) <= 30
),
channel_summary as (
select
customer_id,
channel,
campaign_name,
count(*) as touchpoint_count,
min(event_time) as first_touch,
max(event_time) as last_touch
from pre_purchase_touchpoints
group by 1, 2, 3
)
select * from channel_summary

This gives marketing a clean view of which channels and campaigns touched each converting customer in the 30 days before their first purchase.


Example 4: Fraud Detection Flag

Financial services teams often want to flag transactions that look anomalous without fully blocking them โ€” alerting a fraud review team instead.

models/marts/risk/fct_transaction_risk_flags.sql:

with transactions as (
select * from {{ ref('stg_transactions') }}
),
customer_baselines as (
select
customer_id,
avg(amount_usd) as avg_transaction_amount,
stddev(amount_usd) as stddev_transaction_amount,
count(*) as transaction_count
from transactions
where transaction_date >= dateadd('month', -3, current_date)
group by 1
),
flagged as (
select
t.transaction_id,
t.customer_id,
t.amount_usd,
t.transaction_date,
t.merchant_category,
b.avg_transaction_amount,
b.transaction_count,
case
when b.transaction_count < 5
then 'insufficient_history'
when t.amount_usd > (b.avg_transaction_amount + 3 * b.stddev_transaction_amount)
then 'statistical_outlier'
when t.amount_usd > 10000
then 'high_value_threshold'
else 'normal'
end as risk_flag
from transactions t
left join customer_baselines b on t.customer_id = b.customer_id
)
select * from flagged
where risk_flag != 'normal'

This approach is more nuanced than a simple threshold โ€” it uses each customerโ€™s own history to define what โ€œunusualโ€ means for them.


Example 5: Logistics Delivery Performance

Logistics companies care deeply about whether shipments arrive on time. A transformation model that computes delivery performance gives ops teams something actionable.

models/marts/operations/fct_delivery_performance.sql:

with shipments as (
select * from {{ ref('stg_shipments') }}
),
performance as (
select
shipment_id,
order_id,
carrier_name,
origin_warehouse,
destination_country,
promised_delivery_date,
actual_delivery_date,
datediff('day', promised_delivery_date, actual_delivery_date) as days_delta,
case
when actual_delivery_date is null
then 'in_transit'
when actual_delivery_date <= promised_delivery_date
then 'on_time'
when datediff('day', promised_delivery_date, actual_delivery_date) <= 2
then 'slightly_late'
else 'significantly_late'
end as delivery_status
from shipments
)
select * from performance

The ops team can now group by carrier_name or destination_country to see where delays concentrate.


Testing Your Transformations

Every model above should have accompanying tests. A few examples:

version: 2
models:
- name: fct_monthly_revenue
columns:
- name: revenue_month
tests:
- not_null
- name: total_revenue_usd
tests:
- not_null
- name: fct_delivery_performance
columns:
- name: shipment_id
tests:
- unique
- not_null
- name: delivery_status
tests:
- accepted_values:
values: ['on_time', 'slightly_late', 'significantly_late', 'in_transit']

For fraud detection models, a good singular test checks that the risk flag column only contains known values:

-- tests/assert_valid_risk_flags.sql
select transaction_id
from {{ ref('fct_transaction_risk_flags') }}
where risk_flag not in ('statistical_outlier', 'high_value_threshold', 'insufficient_history')

Transformation Patterns to Know

PatternWhen to use it
Staging model per source tableAlways โ€” clean raw data before any business logic
CTE chainingComplex models โ€” break logic into named steps
ref() for dependenciesAlways โ€” never hardcode schema.table
Incremental materializationLarge tables updated frequently (events, transactions)
Window functions for deduplicationWhen source data has duplicates with no clear key
Coalesce for null handlingWhenever a null would break downstream logic
Case statements for categorizationGrouping continuous values into business-meaningful buckets

Python model files โ€” dbt now supports .py models for cases where SQL falls short. ML feature engineering, calling external APIs during transformation, and complex string parsing are natural fits. The result is still materialized as a table in the warehouse.

Semantic layer โ€” dbtโ€™s MetricFlow integration lets teams define metrics (like revenue, churn_rate, customer_ltv) once in YAML and query them consistently from any BI tool. This reduces the โ€œevery analyst defines revenue differentlyโ€ problem.

Column-level lineage โ€” dbt Cloud now tracks lineage not just at the model level but at the column level. You can see exactly which source column feeds into which output column, making impact analysis much faster.

dbt Mesh for domain ownership โ€” Rather than one team owning all transformation models, large organizations are splitting into domain-owned projects where each team controls their models and publishes stable public interfaces.


The transformation patterns covered here โ€” cleaning, aggregating, joining across domains, flagging anomalies, computing performance metrics โ€” apply across nearly every industry and data stack. The SQL looks slightly different in Snowflake vs. BigQuery vs. Redshift, but the structure of well-written dbt models stays consistent regardless of the underlying warehouse.