Skip to main content

dbt Best Practices

Following best practices ensures your dbt project remains maintainable as it grows. This guide covers proven patterns for data modeling, project organization, and team collaboration.

Project structure​

Layered architecture​

Organize models into clear layers:

models/
├── staging/ # Clean, light transformations
│ ├── core/
│ ├── finance/
│ └── marketing/
├── intermediate/ # Complex business logic
│ ├── customer/
│ └── product/
└── marts/ # Business-facing models
├── core/
├── finance/
└── marketing/

Layer definitions:

Sources: Raw data from external systems Staging: 1:1 with sources, light cleaning Intermediate: Complex transformations, reusable logic Marts: Final business models for analytics

Directory organization​

models/
├── staging/
│ ├── _staging__models.yml # All staging model configs
│ ├── _staging__sources.yml # Source definitions
│ ├── stg_customers.sql
│ ├── stg_orders.sql
│ └── ...
├── intermediate/
│ ├── _intermediate__models.yml
│ ├── int_customer_orders.sql
│ └── ...
└── marts/
├── _marts__models.yml
├── dim_customers.sql
├── fct_orders.sql
└── ...

Naming conventions​

Model naming​

Layer prefixes:

LayerPrefixExample
Stagingstg_stg_customers
Intermediateint_int_customer_orders
Factsfct_fct_orders
Dimensionsdim_dim_customers
Aggregatesagg_agg_daily_sales

Content suffixes:

TypeSuffixExample
Historical_historyfct_orders_history
Current state_currentdim_customers_current
Daily grain_dailyagg_sales_daily

File naming​

stg_[source]__[table].sql          # stg_shopify__orders
int_[entity]_[action].sql # int_customer_enriched
fct_[event].sql # fct_page_views
dim_[entity].sql # dim_products

Column naming​

Primary keys:

-- Always include primary key
select
customer_id, -- Primary key
customer_name,
created_at,
updated_at
from {{ source('raw', 'customers') }}

Timestamp columns:

-- Use consistent timestamp naming
created_at -- When record was created
updated_at -- When record was last modified
valid_from -- Snapshot validity start
valid_to -- Snapshot validity end

Boolean columns:

-- Use is_ or has_ prefix
is_active
is_premium
has_orders
is_valid_email

Data modeling patterns​

Staging models​

Purpose: Clean and standardize raw data

Guidelines:

  • 1:1 with source tables
  • Light transformations only
  • Standardize column names
  • Basic type casting
  • Simple filtering
-- models/staging/stg_customers.sql
{{ config(materialized='view') }}

with source as (
select * from {{ source('raw', 'customers') }}
),

renamed as (
select
id as customer_id,
first_name,
last_name,
email,
created_at,
updated_at,
-- Standardize boolean
case when status = 'active' then true else false end as is_active
from source
)

select * from renamed

Intermediate models​

Purpose: Complex transformations and reusable logic

Guidelines:

  • Combine multiple staging models
  • Business logic transformations
  • Aggregations and calculations
  • Can be referenced by multiple marts
-- models/intermediate/int_customer_orders.sql
{{ config(materialized='view') }}

with customers as (
select * from {{ ref('stg_customers') }}
),

orders as (
select * from {{ ref('stg_orders') }}
),

customer_orders as (
select
c.customer_id,
c.customer_name,
count(o.order_id) as total_orders,
sum(o.amount) as lifetime_value,
min(o.created_at) as first_order_date,
max(o.created_at) as last_order_date
from customers c
left join orders o on c.customer_id = o.customer_id
group by 1, 2
)

select * from customer_orders

Mart models​

Purpose: Business-ready data for analytics

Guidelines:

  • Star schema preferred (facts + dimensions)
  • Clear grain definition
  • Business-friendly naming
  • Document business logic
-- models/marts/fct_orders.sql
{{ config(materialized='table') }}

with orders as (
select * from {{ ref('stg_orders') }}
),

order_items as (
select * from {{ ref('stg_order_items') }}
),

final as (
select
o.order_id,
o.customer_id,
o.order_date,
o.status,
sum(oi.quantity) as total_items,
sum(oi.amount) as total_amount
from orders o
left join order_items oi on o.order_id = oi.order_id
group by 1, 2, 3, 4
)

select * from final

Testing strategy​

Test coverage hierarchy​

Critical models: 100% test coverage

  • Primary keys: unique, not_null
  • Foreign keys: relationships
  • Business-critical fields

Standard models: 80% test coverage

  • Primary keys
  • Key relationships
  • Important metrics

Staging models: Light testing

  • Primary keys
  • Source freshness

Test configuration​

# models/staging/_staging__models.yml
version: 2

models:
- name: stg_customers
description: Staged customer data
columns:
- name: customer_id
description: Primary key
tests:
- unique
- not_null
- name: email
tests:
- not_null
- unique
- name: customer_segment
tests:
- accepted_values:
values: ['enterprise', 'mid_market', 'smb']

Documentation​

Model documentation​

{{
config(
materialized='table',
docs={'node_color': 'gold'}
)
}}

/*
This model calculates daily revenue metrics.

Grain: One row per day

Key Metrics:
- gross_revenue: Total revenue before discounts
- net_revenue: Revenue after discounts
- order_count: Number of orders

Usage:
Used in the executive dashboard and financial reports.
*/

with daily_revenue as (
...
)

Schema.yml documentation​

version: 2

models:
- name: fct_orders
description: >
Fact table containing all orders. One row per order.

**Grain:** order_id

**Owner:** Finance team

**Update Frequency:** Hourly

columns:
- name: order_id
description: Primary key, unique identifier for each order

- name: customer_id
description: >
Foreign key to dim_customers.
Identifies the customer who placed the order.

Code organization​

Macros​

Organize macros by purpose:

macros/
├── tests/ # Custom generic tests
│ ├── test_positive_value.sql
│ └── test_valid_email.sql
├── utils/ # Utility macros
│ ├── cents_to_dollars.sql
│ └── generate_schema_name.sql
└── grants/ # Access control
└── grant_select.sql

Snapshots​

snapshots/
├── customers.sql
├── products.sql
└── _snapshots.yml

Seeds​

seeds/
├── country_codes.csv
├── currency_rates.csv
└── _seeds.yml

Version control​

Git workflow​

Branch naming:

feature/add-customer-model
bugfix/fix-order-amount
hotfix/update-production-credentials

Commit messages:

feat: add customer lifetime value model
fix: correct order amount calculation
docs: update README with new setup instructions
test: add tests for stg_customers

Pull request template​

## Description
Brief description of changes

## Testing
- [ ] dbt test passes
- [ ] Documentation updated
- [ ] Changes tested in staging

## Checklist
- [ ] Models follow naming conventions
- [ ] Tests added for new models
- [ ] No breaking changes to existing models
- [ ] Performance impact considered

Performance considerations​

Materialization choices​

Views:

  • Small datasets (< 100K rows)
  • Simple transformations
  • Infrequently queried
  • Real-time requirements

Tables:

  • Large datasets
  • Complex calculations
  • Frequently queried
  • Snapshot requirements

Incremental:

  • Append-only data
  • Large tables (1M+ rows)
  • Regular updates

Query optimization​

Filter early:

-- Good: Filter in CTE
with recent_orders as (
select *
from {{ ref('stg_orders') }}
where created_at >= current_date - 30
)

-- Avoid: Filter at end
select *
from {{ ref('stg_orders') }}
where created_at >= current_date - 30

Limit CTE usage:

-- Instead of many CTEs, consider intermediate models
-- This enables better caching and reuse

Team collaboration​

Code review checklist​

Structure:

  • Follows naming conventions
  • Properly layered (staging/intermediate/marts)
  • Appropriate materialization

Logic:

  • Business logic is correct
  • Edge cases handled
  • No hardcoded values

Quality:

  • Tests included
  • Documentation complete
  • No performance issues

Onboarding new team members​

Documentation:

  • Project README
  • Architecture diagrams
  • Data dictionary
  • Style guide

Training:

  • dbt fundamentals
  • Project-specific patterns
  • Code review process
  • Deployment procedures

FAQ​

How should I organize my dbt project?​

Use a layered architecture: sources → staging → intermediate → marts. Organize by data domain within each layer (core, finance, marketing, etc.).

What naming conventions should I follow?​

Prefix models by layer: stg_ for staging, int_ for intermediate, fct_ for fact tables, dim_ for dimension tables. Use snake_case consistently.

How do I handle slowly changing dimensions?​

Use dbt snapshots for SCD Type 2. For simple cases, use merge strategies with updated_at timestamps. Document your SCD approach in model descriptions.

When should I use macros vs models?​

Use macros for reusable SQL logic that applies across models. Use models for actual data transformations that produce tables or views.