
dbt in Anger: Modeling, Contracts, Testing, CI, and the Setup That Scales to 500+ Models
The dbt setup that survives real teams — layered models, contracts on every mart, tests that catch business bugs, CI that runs only what changed, semantic layer for one metric one place, and the ops habits that keep the graph healthy at 500+ models.
A dbt project scales when you enforce five habits: strict layering (staging → intermediate → marts), contracts on anything exposed to the business, tests that encode invariants (not just not_null), CI that runs dbt build on modified+ only, and one metric definition per concept via the semantic layer. Get those right and dbt happily carries you past 500 models with a two-person analytics-engineering team.
- Three folders only: staging, intermediate, marts. Resist the urge to invent more.
- Every marts model has a contract, a description, an owner, and at least one business test.
- state:modified+ in CI drops PR runtime from 30 minutes to under 5.
- Source freshness SLAs beat test coverage as an early-warning signal.
- Semantic layer or metrics/ folder — one metric definition everywhere; ban metric math in BI tools.
- Incremental strategy per model, not per project — insert_overwrite for BQ, merge for Snowflake, delete+insert for Postgres.
- Model tags + selectors are how you keep runs cheap: tag critical marts, run them hourly; tag stale ones, run them nightly.
The five habits that separate a dbt project from a dbt swamp
I've inherited enough dbt projects to know the failure mode: 300 models, no layering, tests everywhere and nowhere, CI takes 40 minutes and everyone waits. The fix is boring and repeatable. This guide is the exact playbook I use when a team asks me to unstick their warehouse.
It pairs with modern data stack 2026 (where dbt fits in the wider stack), BigQuery cost control (the warehouse bill dbt drives), and Postgres for product engineers (the source system dbt usually reads from).
The three-layer model, with the boundaries drawn hard
| Layer | Purpose | Materialization | Allowed to reference |
|---|---|---|---|
| staging/ | One model per source table. Rename, cast, dedupe. No joins. | view | sources only |
| intermediate/ | Reusable joins, pre-aggregations, business logic. Not exposed to BI. | ephemeral or view | staging + intermediate |
| marts/ | Business-facing tables. One row per business grain. The contract layer. | table or incremental | intermediate + staging |
Enforce with a linter (SQLFluff + dbt-checkpoint or the built-in dbt-project-evaluator package). Manual review is not enough — the moment someone joins a source directly in a mart, the swamp starts.
Contracts — the schema safety net you didn't know you needed
A dbt contract asserts that a model's output columns, types, and constraints are stable. Change the contract and dbt refuses to run until you version the model. It turns silent schema drift into a build failure — the single cheapest insurance in analytics engineering.
models:
- name: mart_orders_daily
description: "One row per order per day. Source of truth for revenue dashboards."
config:
contract: { enforced: true }
meta:
owner: analytics-eng
pii: false
refresh_sla: 1h
columns:
- name: order_date
data_type: date
constraints: [{ type: not_null }]
- name: order_id
data_type: string
constraints: [{ type: not_null }, { type: unique }]
- name: revenue_usd
data_type: numeric
tests:
- not_null
- dbt_utils.expression_is_true:
expression: ">= 0"Rule: every model under marts/ has an enforced contract. Every intermediate model has at least a described schema. Staging models get away with just not_null on the PK.
Tests worth writing (and the ones that just add noise)
There are four categories of tests. Only three earn their keep.
Structural, referential, invariant, freshness
- Structural — PK uniqueness and not-null on every mart. Cheap, always on.
- Referential — foreign-key style relationships on the joins that matter.
relationshipstest in dbt. - Business invariants — revenue ≥ 0, refunds ≤ original charge, event_time ≤ now(). These catch upstream data bugs your dashboards would otherwise inherit.
- Freshness — SLA on every source table via
freshness:. When a producer breaks, freshness fires before your tests do.
The category to skip: exhaustive not-null tests on every column. They pass forever, add noise on failure, and give false confidence. Test what matters.
severity: warn or error. Only errors fail CI. Warnings surface in the daily digest — the difference between an on-call ping and a Monday triage.sources:
- name: shopify
tables:
- name: orders
loaded_at_field: updated_at
freshness:
warn_after: { count: 2, period: hour }
error_after: { count: 6, period: hour }CI that catches the right things and finishes in 5 minutes
Full dbt build on every PR is slow, expensive, and trains contributors to ignore CI. The recipe:
- In CI, materialize into a PR-scoped schema like
pr_1234. - Fetch prod's
manifest.jsonas the state baseline. - Run
dbt build --select state:modified+ --defer --state ./prod-manifest. - On merge, drop the PR schema in a scheduled cleanup job.
# GitHub Actions excerpt
- run: |
aws s3 cp s3://our-bucket/manifest.json ./prod-manifest/manifest.json
dbt deps
dbt build \
--select state:modified+ \
--defer --state ./prod-manifest \
--target ci --vars '{ pr_id: ${{ github.event.number }} }'Deferred references let modified models point at prod tables for unmodified ancestors — you don't rebuild the world for a one-line change. This is the single change that makes dbt CI feel fast.
Incremental strategies per warehouse — pick on purpose
| Warehouse | Recommended strategy | Why |
|---|---|---|
| BigQuery | insert_overwrite by partition | Cheapest — only rewrites touched partitions |
| Snowflake | merge with cluster keys | Micro-partition pruning + safe re-run |
| Redshift | delete+insert | MERGE improved but delete+insert is more predictable |
| Databricks | merge with liquid clustering | Best cost/perf on Delta |
| Postgres | delete+insert or merge (PG15+) | Small warehouses; keep it simple |
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={ 'field': 'order_date', 'data_type': 'date' },
on_schema_change='append_new_columns'
) }}
SELECT ... FROM {{ ref('stg_orders') }}
{% if is_incremental() %}
WHERE order_date >= date_sub(current_date(), interval 3 day)
{% endif %}table, which is the cheapest thing to reason about.One metric, one place: semantic layer
If active user is defined in four dashboards, all four are wrong. Adopt the dbt Semantic Layer (MetricFlow) or a peer like Cube, and forbid metric math in BI tools. This is the single change that ends the “which number is right?” meeting.
semantic_models:
- name: orders
model: ref('mart_orders')
entities:
- name: order_id
type: primary
dimensions:
- name: order_date
type: time
type_params: { time_granularity: day }
- name: country
type: categorical
measures:
- name: revenue_usd
agg: sum
expr: revenue_usd
metrics:
- name: gross_revenue
type: simple
label: "Gross revenue (USD)"
type_params: { measure: revenue_usd }BI tools query the semantic layer, not the mart directly. Currency, timezone, and business logic live once, in code, versioned.
Docs, exposures, and the graph as documentation
dbt docs are the only up-to-date documentation most data teams will ever ship. Two habits make them worth the disk space:
- Descriptions on every mart column — enforced in CI. If a contributor can't explain a column, it doesn't belong in a mart.
- Exposures for every downstream dashboard, notebook, or reverse-ETL job. This is what turns the lineage graph from a curiosity into an impact-analysis tool: change a mart, dbt tells you which dashboards you're about to break.
exposures:
- name: exec_dashboard
type: dashboard
url: https://looker.example.com/dashboards/42
owner: { name: "CFO team", email: "cfo-team@example.com" }
depends_on: [ref('mart_orders_daily'), ref('mart_costs_daily')]Warehouse cost control from the dbt side
The warehouse bill is 80% driven by three things: over-eager full refreshes, unpartitioned tables, and CI runs that materialize everything. Fixes:
- Tag models by refresh cadence (
hourly,daily,weekly) and run selectors per cadence. - Partition and cluster every mart > 10GB — this is warehouse-specific but always worth it.
- Set
+require_partition_filter: trueon BigQuery marts so nobody queries the full table by accident. - Move ad-hoc exploration to a
sandboxproject with its own budget alerts.
For the full playbook on the warehouse side, see BigQuery cost control that scales and LLM cost dashboard in BigQuery.
Team workflow: PRs, ownership, and the graph review
- CODEOWNERS on marts — domain owners must approve schema changes on their marts.
- Every PR runs dbt-checkpoint — linter, model-must-have-description, model-must-have-tests.
- Weekly graph review — 30-minute session with the whole analytics-eng team. Look at the lineage, prune stale models, promote intermediates to marts when they've earned it.
- Deprecation policy — mark models with
deprecated: true, keep them for one release cycle, then delete. No zombie models.
Migrations: renaming a column without breaking every dashboard
The safe pattern is add-alias-remove, spread across three PRs:
- PR1 — add the new column alongside the old one. Ship. Update the contract to include both.
- PR2 — migrate all downstream references (dashboards, notebooks, exposures). Verify via
dbt list --select +old_columnthat nothing depends on it. - PR3 — drop the old column, update the contract, bump the model version.
This is boring on purpose. Boring analytics engineering is what gets you to 500 models without pager pain.
Observability: elementary or re_data
dbt tells you if a model built successfully. It doesn't tell you if the numbers are quietly wrong — a null spike, a distribution shift, a row-count drop that's within tolerance for tests but obviously anomalous.
Wire in elementary-data or re_data. Both piggyback on the dbt manifest to emit anomaly alerts, freshness dashboards, and lineage-aware incident reports. This is the analytics-engineering equivalent of the AI-observability patterns in observability for AI systems.
A production checklist you can print
- Three folders only (staging/intermediate/marts), enforced by linter.
- Contracts on every mart; descriptions on every mart column.
- Structural, referential, invariant, and freshness tests — categorized and severity-tagged.
- CI runs
state:modified+ --defer, uses PR-scoped schema, drops on merge. - Semantic layer live; BI tools query metrics, not marts.
- Model tags for refresh cadence; separate schedules per tag.
- Every downstream dashboard registered as an exposure.
- CODEOWNERS on marts; deprecation policy documented.
- Elementary or re_data wired into on-call.
- Warehouse cost dashboard live (see BigQuery cost control).
Where to go next
Frequently asked
Incremental models — always?
No. Incremental adds state complexity: backfills, merge windows, late data. Use it when full refresh exceeds your build budget or costs meaningful money. Otherwise <code>table</code> is the cheapest thing to reason about.
dbt Core or dbt Cloud?
Core if you already have CI/CD, orchestration, and hosting sorted. Cloud when you want the IDE, scheduler, and semantic layer without operating them. The modeling patterns in this guide apply identically to both.
How do I keep CI fast on a 500-model project?
state:modified+ with --defer and a prod manifest baseline. This alone drops PR runtime from ~30 minutes to under 5 by only building what changed and its descendants — everything upstream is deferred to prod.
Snapshots vs SCD in the mart?
Snapshots for slowly-changing source rows (customer address history, price history). SCD Type 2 marts for business-facing history you want joinable. They're not the same tool — snapshots capture; marts model.
Is dbt overkill for a 10-model project?
No. Even at 10 models the layering, tests, and docs pay for themselves the first time you onboard a new analyst or debug a dashboard number. The overhead is a day of setup; the payoff starts week two.
Building something similar?
I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.
Work with me
Islam Gamal
AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.
More from Islam Gamal
The Modern Data Stack in 2026: What Actually Matters (and What to Skip)
A no-hype 30-minute tour of the 2026 data stack: ingestion, warehouse, transformation, orchestration, semantic layer, reverse ETL, observability, and the AI layer on top — with concrete tool picks, cost anchors, and the anti-patterns that quietly eat budgets.
BigQuery Cost Control That Scales: 12 Patterns from Real Warehouses
Twelve battle-tested patterns to cut BigQuery spend 30–70% without touching data quality — partitioning, clustering, materialized views, reservations, and the FinOps loop that keeps savings from decaying.
Postgres for Product Engineers: Indexing, JSONB, RLS, and the 20% That Ships Millions of Users
The Postgres playbook I've used across product teams — index type per workload, JSONB without regret, RLS that doesn't leak or crawl, connection pooling that survives spikes, and the observability that turns guessing into ground truth.
Browse every article by Islam Gamal on the author page.