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:
- Every transformation is a
.sqlfile in version control - Dependencies between models are declared explicitly using
ref() - Tests validate data quality on every run
- Documentation lives alongside the code that generates the data
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 tablesClean and rename Business logic AggregationsCast types Derived metrics Report-readyNo aggregation Intermediate calcs Used by BI toolsNot 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 deduplicatedwhere row_num = 1This 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 monthlyorder by revenue_month, product_categoryThe 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_summaryThis 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 flaggedwhere 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 performanceThe 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.sqlselect transaction_idfrom {{ ref('fct_transaction_risk_flags') }}where risk_flag not in ('statistical_outlier', 'high_value_threshold', 'insufficient_history')Transformation Patterns to Know
| Pattern | When to use it |
|---|---|
| Staging model per source table | Always โ clean raw data before any business logic |
| CTE chaining | Complex models โ break logic into named steps |
ref() for dependencies | Always โ never hardcode schema.table |
| Incremental materialization | Large tables updated frequently (events, transactions) |
| Window functions for deduplication | When source data has duplicates with no clear key |
| Coalesce for null handling | Whenever a null would break downstream logic |
| Case statements for categorization | Grouping continuous values into business-meaningful buckets |
2025-2026 Transformation Trends in dbt
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.