ivinco
The dbt Adoption Playbook: From Raw SQL to Modular Analytics Engineering

The dbt Adoption Playbook: From Raw SQL to Modular Analytics Engineering

Ivinco Team·

The dbt adoption that ships in one quarter fails in six months. The team ports 200 stored procedures and scheduled queries into 300 dbt models, all sitting in a flat models/ folder with names like customer_report_final_v2.sql. The framework changed. The dependency graph did not.

This is the Model Sprawl Trap: a dbt project with no layering, no naming convention, and no boundary between raw ingestion and business logic. Every new model references three other models chosen at random, because there is no stated layer for "raw data shaped for downstream use" versus "business entity" versus "reporting view." The graph becomes undirected. Refactors touch everything.

The pattern shows up in teams that adopted dbt as a SQL compiler rather than an analytics engineering discipline. The framework is correctly installed. The models compile. The tests run. But the project has no internal structure, so it carries all the problems dbt was supposed to solve, plus the new operational cost of running dbt Cloud or a self-hosted Airflow orchestrator on top.

What dbt Actually Does

dbt is three things working together:

  • A DAG compiler. dbt reads SQL files, extracts ref() calls, builds a dependency graph, and runs models in the right order.
  • A template engine. Jinja + SQL lets you write reusable macros, parameterize models, and generate repetitive SQL without copy-paste.
  • A test runner. Generic tests (unique, not_null, accepted_values, relationships) and custom tests run against your models and fail the pipeline when data violates expectations.

What dbt is not: a replacement for orchestration (Airflow/Dagster/Prefect still triggers runs), a warehouse (you bring your own Snowflake/BigQuery/Databricks/Redshift/DuckDB), or a catalog (it ships docs, not a metadata platform).

The dbt Developer Hub is the authoritative reference. Start there before any tutorial. dbt Labs' State of Analytics Engineering 2025 survey reports 80% of data practitioners use AI in their workflows and 56% name data quality as a significant problem. dbt's adoption has scaled alongside both — it pairs with Airflow at 44% according to Astronomer's 2026 State of Airflow, with 5,800 respondents across 122 countries. The Cosmos package that bridges dbt into Airflow passed 200 million downloads in 2025.

At that scale, the project-structure decision is load-bearing. Copy-paste a broken pattern from a blog post and you have copied it from a team that later rebuilt.

The Project Structure That Scales

dbt's official structure guide recommends a four-layer organization:

models/
├── staging/
│   ├── stripe/
│   │   ├── _stripe__models.yml
│   │   ├── stg_stripe__customers.sql
│   │   └── stg_stripe__charges.sql
│   └── salesforce/
│       ├── _salesforce__models.yml
│       └── stg_salesforce__accounts.sql
├── intermediate/
│   ├── int_customers_joined.sql
│   └── int_revenue_calculated.sql
└── marts/
    ├── finance/
    │   ├── fct_revenue.sql
    │   └── dim_customers.sql
    └── marketing/
        └── fct_campaign_performance.sql

Staging layer: one model per source table

Every raw source gets exactly one staging model. The staging model is the single place where you rename columns, cast types, and do minimal cleanup. No joins. No aggregations. No business logic. Just the source table, reshaped into the columns and types the rest of the project will reference.

Naming: stg_[source]__[entity]. Two underscores separate the source system from the entity. This matters because it prevents the "which stg_users did I mean, Stripe or Auth0?" question six months in.

Intermediate layer: reusable joins

Intermediate models combine staging models into reusable shapes. This is where you do the expensive joins once and reference them from multiple marts.

Naming: int_[purpose]__[verb_phrase]. int_orders__pivoted_to_user_level.sql is clearer than int_users.sql.

Not every project needs an intermediate layer. Small projects (under 30 marts) often skip it. Larger projects need it to keep marts thin and avoid repeating the same joins across revenue, retention, and attribution models.

Marts layer: business entities

The marts layer is what analysts and BI tools query. Every marts model represents a business entity (users, orders, campaigns) or a business metric (revenue, retention, MRR).

Marts are organized by domain, not source. A finance mart owns revenue, a marketing mart owns attribution. This is the layer where different teams have different models of the same data, and that's the point.

Utilities and seeds

seeds/ holds small static CSVs (country codes, mapping tables) checked into git. macros/ holds reusable Jinja macros. snapshots/ captures Type 2 slowly-changing dimensions. Most projects use snapshots sparingly and seeds heavily.

The Model Sprawl Trap in Detail

When the layer discipline breaks down, specific failure modes appear in sequence:

  1. A new team member adds a model in the wrong layer. An analyst writes customers_with_mrr.sql directly in models/ without putting it in marts/finance/. The model works. Nobody reviews it against the layer conventions.
  2. Two models reference the same raw table directly. Because there is no enforced staging layer, one model reads raw.stripe.charges and another reads a stg_stripe__charges view. The two copies drift. One uses amount_cents, the other uses amount.
  3. A mart model references another mart model. This should be a red flag. Marts should not depend on other marts — if they do, the shared logic belongs in intermediate. But without convention, marts-to-marts references proliferate.
  4. The compile time doubles every quarter. At around 80 models a flat project still feels fast. Past 300 unlayered models, dbt parse times grow noticeably because dbt resolves more cross-references during DAG construction.
  5. A refactor breaks a dashboard nobody owns. Renaming customer_final requires tracking every downstream reference. In a layered project, only marts are consumed by BI — in a sprawled project, anything could be.

The rescue pattern is not "adopt dbt Fusion" or "add more tests." It is drawing a line at the current state and refactoring the project into the four-layer structure — starting with a staging layer, then pulling join logic into intermediate, then renaming any existing model to match its real layer. This costs a sprint. Skipping it costs quarters.

Testing: What to Enforce and Where

dbt's built-in tests come in two types. Generic tests use parameterized Jinja and run across columns and models. Singular tests are one-off SQL queries that must return zero rows to pass.

The four generic tests that ship with dbt cover most needs:

  • unique — no duplicate values in this column
  • not_null — no NULLs
  • accepted_values — column values are in a specified list
  • relationships — foreign key integrity between models

What to enforce where:

  • Staging models: test primary keys (unique, not_null) at minimum. Source freshness configured in sources.yml catches missing loads.
  • Intermediate models: test any join that could produce duplicates (unique on the join key).
  • Marts models: test business logic. If revenue can't be negative, write a singular test that returns any negative rows.

Test severity matters. dbt supports severity: warn vs severity: error, and error_if / warn_if thresholds. A staging primary key test should fail the pipeline. A distribution drift check on a marts model should warn, because drift alone isn't a reason to stop the run.

CI: What to Run on Every Pull Request

A dbt CI that actually catches problems includes three steps:

  1. Parse check. dbt parse validates the project compiles.
  2. Slim CI build. dbt build --select state:modified+ runs only the models that changed (plus their downstream dependents), not the whole warehouse. This requires a manifest.json artifact from the last production run for comparison.
  3. SQL linting. SQLFluff or SQLMesh's SQL validator catches style and semantic errors before review.

The slim CI pattern is what makes dbt CI economically viable at scale. A full dbt build on a 500-model project against a clone schema can run 20-40 minutes depending on warehouse concurrency, Jinja parse time, and test count. Slim CI with state:modified+ typically runs in 1-3 minutes for single-model PRs because it only rebuilds the modified subgraph and its dependents. These ranges vary; measure your own baseline.

Production deployment uses the same dbt build with environment-specific variables, triggered by Airflow (typical pattern: Cosmos operators), Dagster asset checks, or dbt Cloud's own scheduler.

Orchestration: Cosmos or dbt Cloud or Airflow Operators

Three legitimate orchestration patterns exist:

  • Airflow + Cosmos. Astronomer's Cosmos package parses your dbt project into native Airflow tasks — one task per model. This gives you per-model retries, per-model logs, and DAG visualization in the Airflow UI. The 200M downloads in 2025 reflects how many teams already chose this pattern.
  • dbt Cloud. The managed option. Schedules, logs, CI integration, and documentation hosting. Priced per developer seat; fine for teams under 20 practitioners, expensive at scale.
  • Airflow BashOperator / PythonOperator. The "simple" option that looks like 10 lines of Python wrapping dbt run --select tag:daily. It works, but you lose per-model visibility and retry granularity. Suitable for small projects.

Dagster's dbt integration works. The install base is smaller because most teams adopting dbt already run Airflow.

dbt Fusion: What We Can Say Honestly

dbt Labs released the dbt Fusion engine in 2025, rewritten in Rust with claimed 30x faster parse times and 2x faster full-project compile. BigDATAwire reported the announcement details; dbt Labs' own blog frames Fusion as "lightning-fast parse times, up to 30x faster than dbt Core, allowing large dbt projects to execute in milliseconds instead of minutes."

Honest read: these are vendor-published numbers, measured on large projects where parse time is a meaningful bottleneck. For a 50-model project, the absolute gain is negligible — parse was fast already. For a 1,500-model project on Snowflake, the difference is real and the workflow feels different.

Fusion's availability is expanding. At launch it supported Snowflake; Databricks, BigQuery, and Redshift adapters were planned to follow. If your project is large enough that dbt parse feels slow, Fusion is worth testing. If you're adopting dbt for the first time, dbt Core is the right starting point and the migration is straightforward later.

Where we do not have a firm answer: the breakeven point in model count where Fusion's speedup justifies the adoption overhead. Most published case studies are hyperscale dbt Labs customers. For mid-sized projects (100-300 models), we have seen teams install Fusion, like the VS Code experience, and quietly keep running dbt Core in production because the CI pipeline was already fast enough.

Common Adoption Mistakes

The patterns that cause the most pain, in descending frequency:

  1. Flat models/ folder. Skipping the staging/intermediate/marts layering to "ship fast." The cost compounds: the third quarter into adoption is when teams stop shipping features because every change touches the same ambiguous middle layer.
  2. No ref() discipline. Writing FROM raw.stripe.charges in a model instead of {{ ref('stg_stripe__charges') }}. dbt can't build the DAG without ref().
  3. Testing everything, enforcing nothing. Adding 200 tests set to severity: warn means failures are ignored. Pick the critical tests, set them to error, and fail the build.
  4. Treating the warehouse as the test environment. Running dbt build against production to see what happens. Use a separate schema or clone and promote tested artifacts.
  5. No source freshness configuration. sources.yml with freshness: thresholds tells dbt when a source table hasn't updated in the expected window. Without it, staleness is invisible until analysts complain.
  6. Seed files used for production data. Seeds are for static mappings under 100KB. Using them for data that changes weekly turns every deploy into a data push.
  7. Single enormous mart. A 500-line fct_revenue.sql that does seven joins, four CTEs, and three subqueries. Break it apart into intermediate models. The final mart should be readable in one screen.
  8. No dbt docs hosted. dbt docs generate + a hosted artifact is the lowest-cost analyst interface to understand the warehouse. Without it, column-definition questions land in Slack indefinitely — the knowledge is in someone's head, not the project.

How Much dbt Is Enough dbt

Not every transformation should be in dbt.

  • Machine learning feature engineering with Python is often better served by Feature Store patterns (Feast, Tecton) or dbt-python models with Snowpark/BigQuery notebooks.
  • Real-time features belong in your streaming layer, not dbt, which is a batch tool.
  • One-off analytical queries for Jira tickets don't need the dbt overhead. An analyst writing ad-hoc SQL in Snowflake's UI is fine.

dbt's value is in the transformations that appear on a dashboard, drive a metric, or feed a downstream model. For those, dbt pays. For throwaway analysis, it doesn't.

dbt is not the tool. The project structure is.


Need help structuring a dbt project or escaping the Model Sprawl Trap? Talk to an engineer — we'll tell you honestly if we can help.

Frequently Asked Questions

How do you structure a dbt project for scale?

Use four layers: staging (one model per source table, no joins), intermediate (reusable joins that feed multiple marts), marts (business entities and metrics consumed by BI), and utilities (seeds, macros, snapshots). Naming matters: stg_[source]__[entity], int_[purpose], fct_/dim_ for marts. This structure is dbt's official recommendation and scales to 1,000+ models.

What tests should every dbt project have?

At minimum, test primary keys (unique + not_null) on staging models and any intermediate model with joins that could produce duplicates. Test relationships between foreign keys. Use accepted_values for categorical columns with a finite set. Add singular tests for business invariants (revenue not negative, dates not in the future). Set critical tests to severity: error; drift monitors to severity: warn.

How does dbt compare to stored procedures?

Stored procedures execute SQL but don't track dependencies, don't version-control cleanly, and don't produce a lineage graph. dbt compiles SQL into a dependency-aware DAG, versions transformations in git, and generates documentation automatically. The practical gain on migration is the ability to refactor: renaming or restructuring one model's logic shows every downstream impact in the DAG rather than requiring a grep across procedure files.

Should you use dbt Core or dbt Cloud?

dbt Core is free, open-source, and runs anywhere. dbt Cloud is the managed product with scheduling, IDE, CI integration, and docs hosting. Core fits teams with existing orchestration (Airflow + Cosmos), budget constraints, or compliance requirements. Cloud fits teams under 20 practitioners without an orchestration investment and willing to pay per-developer pricing. Both compile identical SQL.

Is dbt Fusion worth the upgrade from dbt Core?

dbt Fusion delivers up to 30x faster parse times and 2x faster full-project compilation (vendor-measured, on large projects). For projects under 100 models, the absolute gain is negligible. For projects over 500 models where parse time has become a CI bottleneck, the speedup is real. Test Fusion in a branch before migrating production if you're already running a large dbt Core project.