Engineering reference
Best practices and anti-patterns for the gap between tutorial and production: project structure, model design, strategic testing, Slim CI, and documentation worth reading.
The official dbt documentation teaches the mechanics in an isolated, clean environment. Production is not a clean environment. Production is messy source schemas, 500-model DAGs that nobody can visualise, incremental models with wrong unique keys that silently duplicate records, and CI runs that cost $50 each and run on every commit. The tutorial does not prepare you for any of that.
This handbook covers the five realities of production dbt: the project structure that prevents DAG collapse, the twelve model anti-patterns that appear repeatedly in real projects, the testing strategy that distinguishes what matters from what creates noise, the CI approach that keeps build costs rational, and the documentation standard that means something to an analyst at 2am. Everything here comes from real projects, not a sandbox environment.
Before structure and anti-patterns: the things the tutorials omit.
Tutorials use clean CSVs. In production, sources change schemas without warning, contain nested JSON blobs, have duplicate rows, and arrive late. The staging layer exists specifically to absorb this. If source chaos bleeds through staging into marts, every downstream model is exposed.
Tutorials default to view materialisation to save time. Views on top of views on top of views cause warehouse timeouts and unpredictable compute costs. Materialisation strategy is not a performance optimisation: it is a cost decision that must be made deliberately at each layer.
A 10-model project is manageable. A 500-model project without enforced layers and naming conventions becomes an unnavigable hairball. The structure below is not optional at scale.
If every column has a not_null test, the pipeline is always failing on something. True production engineering requires targeted, strategic testing and a deliberate distinction between warnings and errors. Over-testing is not rigour: it is noise.
State management and Slim CI are not advanced features: they are the cost controls that make a production dbt project economically viable. Learn them before the project reaches scale, not after.
Three mandatory layers. Enforce the separation strictly. The names are not suggestions.
models/staging/): The only layer that reads from raw source() data. Purpose: light cleaning, renaming to standard conventions, casting data types. One staging model per raw table. No joins, no business logic.models/intermediate/): Where complex logic lives. Joins, complex aggregations, window functions. Building blocks that are not yet ready for end-users. Nothing in this layer should be queried directly by a BI tool.models/marts/): The business layer. Wide, denormalised tables ready for BI tools. End-users query these. Grain should be explicit in documentation or model name.├── dbt_project.yml
├── packages.yml
├── models/
│ ├── staging/
│ │ ├── stripe/
│ │ │ ├── _stripe__sources.yml
│ │ │ ├── _stripe__models.yml
│ │ │ ├── stg_stripe__payments.sql
│ │ │ └── stg_stripe__customers.sql
│ │ └── salesforce/
│ ├── intermediate/
│ │ └── finance/
│ │ └── int_payments_pivoted_to_customers.sql
│ └── marts/
│ ├── finance/
│ │ ├── _finance__models.yml
│ │ ├── fct_payments.sql
│ │ └── dim_customers.sql
│ └── marketing/
├── macros/
│ └── cents_to_dollars.sql
└── tests/
└── assert_positive_value_for_total_amount.sql
Configuration notes: use prefixes (stg_, int_, fct_, dim_) consistently. Group YAML config files by source or mart directory, not at project root. Use dbt-utils or dbt-expectations before writing custom macros for standard operations like surrogate keys.
Each of these appears in real production codebases. Most of them are still running.
stg_ model.WHERE created_at >= '2023-01-01' or WHERE date = CURRENT_DATE.var('start_date')) or macros. Historical backfills require this. CI runs require this. Hardcoded dates are discovered at the worst possible time.
fct_user_daily_activity). Analysts joining two marts tables at different grains produce wrong numbers silently.
is_incremental() filtering. This is the correct use case for incremental models.
unique_key in incremental models. Building an incremental model without defining unique_key.unique_key, typically a surrogate key via dbt_utils.generate_surrogate_key. Without it, every incremental run appends duplicates silently.
not_null on every column in a 200-column table.raw_db.stripe.charges directly without a sources.yml definition.The goal is not comprehensive test coverage. The goal is targeted testing of what matters, with a clear distinction between failures that stop the pipeline and failures that generate a warning.
['pending', 'success', 'failed']).Write custom tests only when business logic requires it. This is an example of a test that belongs in production: it catches a real failure mode.
-- tests/assert_order_total_matches_line_items.sql
with orders as (
select * from {{ ref('fct_orders') }}
),
line_items as (
select * from {{ ref('fct_order_line_items') }}
)
select
o.order_id,
o.total_amount,
sum(l.item_amount) as calculated_amount
from orders o
left join line_items l on o.order_id = l.order_id
group by 1, 2
having o.total_amount != sum(l.item_amount)
If this query returns rows, the test fails. The test question being asked: does the order total match the sum of its line items? This is a business invariant that should never be violated. Tests like this are worth writing. Tests on every column of a dim table are not.
severity: warn: Data quality degradation that should not stop the pipeline. A user missing an email address is a data quality signal, not a deployment blocker.severity: error: Pipeline-breaking issues that must halt execution. A duplicate primary key in a fact table is an error. Letting it through corrupts downstream reporting.sources.yml. Run dbt source freshness on a schedule, not on demand.Materialisation is a cost decision. Make it deliberately at each layer.
Warehouse-specific optimisation example for BigQuery. Align partition keys with the filters the BI tool uses most frequently:
{{
config(
materialized='table',
partition_by={
"field": "created_at",
"data_type": "timestamp",
"granularity": "day"
},
cluster_by=['customer_id', 'status']
)
}}
Before making a model incremental: does rebuilding this table in full cost more than the engineering time required to maintain incremental logic? For tables under 10 GB, the answer is almost always no. Full rebuild is cheaper and safer.
Running dbt build on the entire project for every pull request is the default configuration. It is also the configuration that makes a large project economically unrunnable. Slim CI is the fix.
The principle: when a PR is opened, build only the models that changed and their downstream dependencies. Defer to the production run for everything else.
# Three steps:
# 1. Production job produces manifest.json after every successful run.
# 2. CI job downloads the production manifest.json.
# 3. CI job runs:
dbt build --select state:modified+ --defer --state ./prod-run-artifacts
This builds only modified models and their children. Upstream dependencies are deferred to production, which means CI tests the actual changes against real production data without rebuilding the entire DAG.
dbt_alice, dbt_bob). Isolates development from shared environments.dbt_pr_123). Schema is dropped when the PR merges.analytics or marts schemas. Runs from the main branch only.dbt build --select marts.--full-refresh on weekends to correct incremental drift.The documentation that matters is not comprehensive column documentation. It is the information an analyst needs at 2am answering a CEO question.
Document three things: the grain of the table (one row per what?), complex join decisions (why left instead of inner?), and critical business metrics (revenue, is_active, churn_date). Everything else is noise.
# _marts__models.yml
version: 2
models:
- name: fct_orders
description: '{{ doc("fct_orders_description") }}'
columns:
- name: order_id
description: Primary key.
tests:
- unique
- not_null
- name: status
description: The fulfilment state.
tests:
- accepted_values:
values: ['placed', 'shipped', 'completed', 'returned']
# docs.md
{% docs fct_orders_description %}
This fact table contains all orders placed by customers.
Grain: one row per order.
Important: Excludes test orders created by internal staff
(filtered via is_test_account = false).
{% enddocs %}
The note about is_test_account = false is the documentation that matters. Without it, the next analyst to query the table will include internal test orders in revenue figures and spend two hours investigating a discrepancy that does not exist.
Nauman Shahid builds zero-dependency data infrastructure for organisations in the UAE and Gulf region. If your production dbt project has grown beyond the point where a single engineer can reason about the full DAG, a diagnostic engagement identifies the structural issues: www.mindflex.tech.
These documents come from live diagnostic work. If your data infrastructure, vendor exposure, or compliance posture needs attention:
Discuss a diagnostic engagement →