Data as of Aug 25, 2026 · Based on 319 AI responses from ChatGPT Search and Google AI Mode · See how Parse measures this
Brands AI recommends here
Named in 81% of answers
Named in 35% of answers
Named in 22% of answers
Named in 19% of answers
Structuring a large **dbt (data build tool)** project requires shifting from a monolithic "dump-everything-here" mindset to a clean, modular software-engineering approach. The gold standard for achieving this scalability is the **layers-based architecture** popularized by fishtown analytics (dbt Labs) and refined by…
Structuring a large dbt (data build tool) project requires shifting from a monolithic "dump-everything-here" mindset to a clean, modular software-engineering approach. The gold standard for achieving this scalability is the layers-based architecture popularized by fishtown analytics (dbt Labs) and refined by the community.
Here is a blueprint to structure your project for maximum maintainability and scale:
Divide your models into clear, sequential schemas or directories. Data flows strictly forward from one layer to the next.
staging/ or stg/):
intermediate/ or int/ - Optional):
marts/ or fct_ / dim/):
fct_orders, dim_customers ). This is the only layer downstream users and BI tools (like Looker, Tableau, or Preset) should query.Organize your folders either by domain/source in staging and by business entity in marts. A clean layout looks like this:
text``` my_dbt_project/ ├── analyses/ ├── macros/ ├── models/ │ ├── intermediate/ │ │ ├── finance/ │ │ │ (_int_finance_models.yml, int_payments_combined.sql) │ │ └── marketing/ │ └── marts/ │ ├── core/ │ │ (dim_customers.sql, fct_orders.sql, core.yml) │ └── finance/ │ (fct_revenue.sql, finance.yml) ├── seeds/ ├── snapshots/ ├── tests/ └── dbt_project.yml
Use code with caution.
3. Key Scaling Best Practices
- *Use Source Declarations (`sources.yml`):* Never hardcode raw table names in your staging models using `from source_db.schema.table` . Always define them in a `sources.yml` file and call them using the `{{ source('source_name', 'table_name') }}` function. This ensures lineage tracking works from day one.
- *Leverage Groups and Contracts (Enterprise):* For massive multi-team dbt projects, use **dbt mesh / contracts** to enforce data types and primary key constraints on public models, preventing upstream changes from breaking downstream BI dashboards.
- *DRY Up Code with Packages:* Don't reinvent the wheel. Use community-loved packages like [dbt_utils](https://google.com/goto?url=CAESaQHrOzAVz7SpA5YDNKGup-akzmYvWV6E6Hn8cS0hDxTis9vFLdRAcR3DEqHGL_tmbZm9X3YjW313Li7JDSkwe0eR5DO77VtmC4vxMZdAKwRX4CNpRiwSSTu2Wslu5t5QoG965riHwFs-Vg) for cross-database general macros and dbt_expectations for advanced data testing.
- *Enforce Naming Conventions:* Standardize prefixes so anyone on the team knows what a model is just by looking at its filename:
- `stg_` for staging
- `int_` for intermediate
- `fct_` for fact tables (events, transactions)
- `dim_` for dimension tables (entities, people, places)
Would you like me to dive deeper into **how to configure `dbt_project.yml`** for materializations, or do you want an example of a **staging-to-mart model workflow** for a specific domain like e-commerce or SaaS?
No sources cited
For a **large dbt project**, optimize for three things: **clear ownership, predictable dependencies, and small units of change**. A good structure should let someone unfamiliar with the project answer “where does this logic belong?” without asking another engineer. The classic **staging → intermediate → marts**…
For a large dbt project, optimize for three things: clear ownership, predictable dependencies, and small units of change. A good structure should let someone unfamiliar with the project answer “where does this logic belong?” without asking another engineer.
The classic staging → intermediate → marts pattern is still a strong foundation, and dbt Labs continues to recommend it for separating source preparation, reusable transformations, and business-facing models.
A scalable starting point:
dbt_project/
├── dbt_project.yml
├── packages.yml
├── macros/
├── models/
│ ├── staging/
│ │ ├── salesforce/
│ │ │ ├── _salesforce__sources.yml
│ │ │ ├── stg_salesforce__accounts.sql
│ │ │ ├── stg_salesforce__contacts.sql
│ │ │ └── salesforce.yml
│ │ ├── stripe/
│ │ │ ├── _stripe__sources.yml
│ │ │ ├── stg_stripe__customers.sql
│ │ │ └── stg_stripe__payments.sql
│ │ └── app/
│ │
│ ├── intermediate/
│ │ ├── customers/
│ │ │ ├── int_customers__enriched.sql
│ │ │ └── int_customers__orders.sql
│ │ └── orders/
│ │ └── int_orders__enriched.sql
│ │
│ └── marts/
│ ├── core/
│ │ ├── dim_customers.sql
│ │ ├── dim_products.sql
│ │ └── fct_orders.sql
│ ├── finance/
│ ├── marketing/
│ └── product/
│
├── seeds/
├── snapshots/
├── tests/
└── analyses/
The important distinction is that staging is organized around source systems, while marts are organized around business domains. This makes ownership and discovery much easier as the number of sources and models grows.
Think:
“Make this source usable and consistent.” A staging model should generally:
Avoid putting business logic here.
select
id as customer_id,
email as customer_email,
created_at::timestamp as created_at,
status
from {{ source('stripe', 'customers') }}
The key benefit is that if Stripe changes created_at, you fix it once, rather than fixing dozens of downstream models. That's one of the main reasons dbt recommends a staging layer.
Use a predictable naming convention such as:
stg_<source>__<entity>
For example:
stg_salesforce__accounts
stg_salesforce__contacts
stg_stripe__customers
stg_stripe__payments
This is where complicated work goes:
stg_customers
│
stg_orders ──→ int_customers__orders
│ │
stg_payments ────────┘
│
↓
dim_customers
Use intermediate models for:
Name them around what they accomplish, e.g.:
int_orders__enriched
int_customers__orders
int_subscription__status
Don't let your marts become 800-line SQL queries simply because there's nowhere else to put the complexity.
dbt Labs explicitly recommends modularizing shared logic into its own models to keep the DAG and transformations manageable.
Marts should answer:
“What data should analysts and applications actually consume?” Organize these by domain:
marts/
├── finance/
├── marketing/
├── product/
├── sales/
└── core/
And use recognizable model names:
dim_customers
dim_products
fct_orders
fct_payments
The dim_ / fct_ convention makes the intended grain and model type immediately recognizable.
A healthy large project tends to look roughly like:
RAW SOURCES
│
┌──────────┴──────────┐
↓ ↓
STAGING STAGING
Salesforce Stripe
│ │
└──────────┬──────────┘
↓
INTERMEDIATE
│
┌──────────┼──────────┐
↓ ↓ ↓
FINANCE PRODUCT MARKETING
MART MART MART
Avoid patterns like:
fct_orders
├── raw_orders
├── raw_customers
├── raw_payments
├── raw_products
├── another_mart
└── some_random_report
That creates hidden coupling and makes changes increasingly dangerous.
A useful rule is:
Dependencies should generally flow downward through the layers, never sideways or backward. For example, a staging model shouldn't depend on a marketing mart.
This becomes particularly important around 100+ models.
Suppose both Finance and Marketing need customer lifetime value.
Don't create:
finance/int_customer_ltv.sql
marketing/int_customer_ltv.sql
with subtly different implementations.
Instead, establish a shared canonical model:
intermediate/
└── customers/
└── int_customers__lifetime_value.sql
Then:
finance ──────┐
├──> int_customers__lifetime_value
marketing ────┘
This prevents business definitions from diverging.
But don't over-generalize everything into a giant "common" layer. Shared logic should actually be shared.
dbt_project.yml enforce the architectureDon't rely solely on engineers remembering conventions.
For example:
models:
my_project:
staging:
+materialized: view
+schema: staging
intermediate:
+materialized: view
+schema: intermediate
marts:
+materialized: table
+schema: marts
Then override individual expensive models when necessary:
models:
my_project:
intermediate:
+materialized: view
orders:
+materialized: table
marts:
+materialized: table
finance:
+schema: finance
Materialization should be driven by workload and reuse, not ideology. Staging views are common; heavily reused or expensive intermediate models may deserve tables/incremental materialization; marts are often persisted for consumers.
Don't create a giant:
models.yml
containing 500 models.
Instead:
staging/
└── stripe/
├── stg_stripe__customers.sql
├── stg_stripe__payments.sql
└── stripe.yml
marts/
└── finance/
├── fct_payments.sql
├── dim_customers.sql
└── finance.yml
That keeps ownership localized and makes PRs much easier to review. The dbt Project Evaluator similarly recommends keeping tests/documentation close to the models they describe.
For important models, document at least:
For example:
models:
- name: fct_orders
description: >
One row per customer order.
columns:
- name: order_id
description: "Unique identifier for an order."
tests:
- unique
- not_null
One of the biggest sources of dbt pain is ambiguous grain.
Every fact model should effectively answer:
What does one row represent? For example:
fct_orders
grain = one row per order
fct_order_items
grain = one row per order line item
fct_daily_customer_activity
grain = one row per customer per day
Put that information in the model description or SQL comment and test the key.
A model called customer_metrics that could mean one row per customer, customer-day, customer-product, or customer-month is a maintenance problem waiting to happen.
Macros are excellent for repeated technical patterns:
macros/
├── generate_surrogate_key.sql
├── safe_cast.sql
├── date_spine.sql
└── cents_to_dollars.sql
But don't put business logic into enormous macros just to avoid writing SQL.
Prefer:
int_customer_ltv.sql
over:
{{ calculate_everything_about_customers() }}
Models are much easier to discover, lineage-track, test, and debug.
At scale, you don't want every PR rebuilding the entire warehouse.
Use tags, selectors, state comparison, and DAG-aware CI so a change to:
models/marts/marketing/
doesn't unnecessarily rebuild unrelated Finance models.
For example, conceptually:
PR changes
↓
affected models
↓
their downstream dependencies
↓
tests
This becomes increasingly important as projects move from dozens to hundreds or thousands of models.
Every important domain should have an owner:
Finance → Finance Analytics
Marketing → Marketing Analytics
Product → Product Analytics
Core customer → Data Platform
You can encode ownership through metadata/tags and reinforce it with CODEOWNERS or your team's review system.
The goal is that when someone sees:
fct_revenue
they know:
I generally wouldn't split a large dbt codebase into many separate projects just because it has become large.
A single well-organized project often gives you:
Split projects when you have genuine boundaries such as independent teams, security boundaries, release cycles, or operational requirements—not merely because the model count is high.
If I were starting a large project today, I'd aim for something like:
models/
│
├── staging/
│ ├── salesforce/
│ ├── stripe/
│ ├── postgres/
│ ├── app/
│ └── ad_platforms/
│
├── intermediate/
│ ├── customers/
│ ├── orders/
│ ├── subscriptions/
│ └── marketing/
│
└── marts/
├── core/
├── finance/
├── marketing/
├── product/
├── sales/
└── operations/
With these rules:
| Layer | Organize by | Main purpose | Typical materialization |
|---|---|---|---|
staging | Source | Clean/conform raw data | View |
intermediate | Entity/process | Reusable transformations | View/table/incremental |
marts | Business domain | Consumer-facing data products | Table/incremental |
The most important principle is not the exact folder names. It's establishing a small number of architectural rules and making them hard to violate.
1. Raw sources are referenced through source().
2. Staging models represent individual source entities.
3. Staging contains no business logic.
4. Complex/reusable transformations belong in intermediate.
5. Marts are organized by business domain.
6. Every mart has an explicit grain.
7. fct_ = fact; dim_ = dimension; int_ = intermediate; stg_ = staging.
8. Tests and documentation live close to their models.
9. Shared business logic has one canonical implementation.
10. CI should build only the affected DAG wherever practical.
That combination—layered architecture + domain ownership + explicit grain + modular DAG + automated standards—is what makes a dbt project scale. The directory structure itself is only the visible part of the architecture.
If you're dealing with a project that's already large and messy, the best strategy is usually incremental refactoring rather than a rewrite: establish the conventions, migrate one domain at a time, and use the DAG to identify duplicated or overly coupled logic.
Dependencies should generally flow downward through the layers, never sideways or backward. For example, a staging model shouldn't depend on a marketing mart.
This becomes particularly important around 100+ models.
Suppose both Finance and Marketing need customer lifetime value.
Don't create:
finance/int_customer_ltv.sql
marketing/int_customer_ltv.sql
with subtly different implementations.
Instead, establish a shared canonical model:
intermediate/
└── customers/
└── int_customers__lifetime_value.sql
Then:
finance ──────┐
├──> int_customers__lifetime_value
marketing ────┘
This prevents business definitions from diverging.
But don't over-generalize everything into a giant "common" layer. Shared logic should actually be shared.
dbt_project.yml enforce the architectureDon't rely solely on engineers remembering conventions.
For example:
Structuring a large dbt (data build tool) project requires `moving away from the default "throw everything into the models folder" approach and adopting a modular, tiered architecture` . The goal is to enforce clear boundaries, predictable lineage, and easy onboarding for new team members. Here is a proven blueprint…
Structuring a large dbt (data build tool) project requires moving away from the default "throw everything into the models folder" approach and adopting a modular, tiered architecture . The goal is to enforce clear boundaries, predictable lineage, and easy onboarding for new team members.
Here is a proven blueprint to structure a large-scale dbt project for long-term maintainability and scalability.
Organize your models/ directory into distinct operational layers. Data flows strictly downstream: Staging → Intermediate → Marts.
Layer 1: Staging (models/staging/)
The foundation. This is where you connect to your raw sources 1-to-1.
stg_).models/staging/stripe/stg_stripe__payments.sqlLayer 2: Intermediate (models/intermediate/)
The transformation and preparation zone.
models/intermediate/finance/int__orders_enriched.sqlLayer 3: Marts (models/marts/)
The business-facing layer. This is what your BI tools (Looker, Tableau, etc.) and stakeholders query.
finance, marketing, product ). Models here are materialized as tables or incremental models. They consume from staging or intermediate layers—never raw sources directly.models/marts/finance/fct_revenue.sqlConsistency is key to readability when a project hits hundreds of models.
stg_ for Stagingint_ for Intermediatefct_ for Fact tables (Marts)dim_ for Dimension tables (Marts)staging , group by source system (e.g., salesforce, zendesk, stripe ). Under marts , group by business domain (e.g., core, finance, product).dbt-expectations or Core Tests: Define unique and not_null tests on every primary/foreign key at the staging and mart layers. Use the dbt Package Hub to pull in community-tested assertion packages.materialized='incremental' early, but ensure you write robust is_incremental() logic to handle late-arriving data and deduplication cleanly.target.name or environment variables via {{ env_var('DBT_ENV') }} in your profiles.yml.If you'd like, let me know:
I can dive deeper into incremental strategies or how to set up access controls for your specific setup.
No sources cited
For a large dbt project, I’d optimize for **clear ownership, predictable dependencies, small models, and cheap selective builds** rather than trying to minimize the number of files. A good default architecture is: ```
For a large dbt project, I’d optimize for clear ownership, predictable dependencies, small models, and cheap selective builds rather than trying to minimize the number of files.
A good default architecture is:
dbt_project/
├── dbt_project.yml
├── packages.yml
├── models/
│ ├── staging/
│ │ ├── salesforce/
│ │ │ ├── src_salesforce.yml
│ │ │ ├── stg_salesforce.yml
│ │ │ ├── stg_salesforce__accounts.sql
│ │ │ └── stg_salesforce__opportunities.sql
│ │ ├── stripe/
│ │ │ ├── src_stripe.yml
│ │ │ ├── stg_stripe.yml
│ │ │ └── ...
│ │ └── product/
│ │
│ ├── intermediate/
│ │ ├── finance/
│ │ ├── marketing/
│ │ └── product/
│ │
│ └── marts/
│ ├── finance/
│ │ ├── dim_accounts.sql
│ │ ├── fct_revenue.sql
│ │ └── finance.yml
│ ├── marketing/
│ ├── product/
│ └── core/
│
├── macros/
├── tests/
├── snapshots/
├── seeds/
└── analyses/
This broadly follows dbt's recommended staging → intermediate → marts pattern.
A staging model should usually correspond to one source table:
source → stg_salesforce__accounts
source → stg_stripe__customers
Use it for:
Avoid putting business logic or cross-source joins here. The goal is to create a stable interface between your raw systems and the rest of the project. dbt specifically recommends staging as a modular layer that reduces downstream duplication and code drift.
This is where I'd put:
stg_orders
+
stg_customers
↓
int_orders__enriched
Examples:
An intermediate model should have one clear purpose. Don't create int_model_47 that becomes a dumping ground.
Marts are the interface consumers should care about:
marts/
├── finance/
│ ├── fct_revenue
│ └── dim_customer
├── marketing/
│ ├── fct_campaign_performance
│ └── dim_campaign
└── product/
├── fct_events
└── dim_user
Use fct_ and dim_ consistently where appropriate; dbt also uses this fact/dimension convention for mart models.
The important distinction is:
Layers describe transformation responsibility; domains describe business ownership. That's why I prefer
marts/finance/,marts/marketing/, etc. over one enormousmarts/directory.
This is one of the most useful scaling rules.
Staging:
staging/
├── salesforce/
├── stripe/
├── postgres/
└── app/
Marts:
marts/
├── finance/
├── marketing/
├── product/
└── operations/
That makes two questions easy to answer:
"Where does this data come from?" →
staging/stripe/
"Who owns this analytical dataset?" →
marts/finance/
dbt's project-evaluator guidance similarly recommends source-specific staging directories and keeping model tests/documentation close to their models.
Establish conventions early and enforce them.
For example:
stg_<source>__<entity>
int_<entity>__<transformation>
fct_<business_event>
dim_<business_entity>
Examples:
stg_stripe__payments
stg_salesforce__opportunities
int_orders__enriched
int_customers__first_purchase
fct_orders
fct_subscription_revenue
dim_customers
dim_products
The prefixes communicate the model's role without opening the SQL file.
I'd also make grain explicit.
For example, at the top of fct_orders.sql:
-- Grain: one row per order
Then test that grain:
columns:
- name: order_id
data_tests:
- not_null
- unique
A model's grain is arguably more important than its filename.
Avoid this:
models/
├── 400 SQL files
└── schema.yml
Instead:
finance/
├── fct_revenue.sql
├── dim_customer.sql
└── finance.yml
And:
stripe/
├── stg_stripe__customers.sql
├── stg_stripe__payments.sql
├── src_stripe.yml
└── stg_stripe.yml
This keeps documentation, tests, and code discoverable together and reduces giant YAML files and merge conflicts.
For very large directories, splitting YAML further by model is perfectly reasonable.
Don't blindly make everything a table—or everything ephemeral.
A reasonable starting point is:
models:
my_project:
staging:
+materialized: view
intermediate:
+materialized: view
marts:
+materialized: table
Then override based on actual workload.
For example:
{{ config(
materialized='incremental',
unique_key='order_id'
) }}
for a very large fact table.
Staging views are particularly useful because they provide a reusable normalization layer without unnecessarily duplicating the raw data.
I'd be cautious with ephemeral models. They can reduce database objects, but they make debugging harder because there's no relation you can query directly.
This is an easy trap:
int_orders_step_1
int_orders_step_2
int_orders_step_3
int_orders_step_4
int_orders_step_5
fct_orders
That's not necessarily modularity; it may just be fragmentation.
Create an intermediate model when it:
A 50-line model doesn't need to become five 10-line models.
A healthy dependency direction looks like:
Sources
↓
Staging
↓
Intermediate
↓
Marts
↓
BI / ML / applications
Try hard to prevent:
mart → staging
mart_a → mart_b → mart_c
staging_a → staging_b
especially the last one.
Cross-domain dependencies should also be intentional. If Finance depends directly on 17 Marketing models, you've created an architectural coupling problem.
Instead, create stable shared entities:
staging
↓
intermediate/core
↓
dim_customer
↓
finance
marketing
product
Think of high-value marts/models as internal APIs.
At minimum, test:
For example:
models:
- name: fct_orders
description: "One row per customer order."
columns:
- name: order_id
description: "Unique identifier for the order."
data_tests:
- not_null
- unique
- name: customer_id
data_tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
Don't try to test every column equally. Put the strongest guarantees around business-critical interfaces.
At hundreds or thousands of models, running the entire project for every PR becomes painful.
Use dbt's graph selection capabilities so that a change can trigger something like:
changed model
↓
downstream models
↓
relevant tests
rather than rebuilding unrelated domains.
Also consider tags/groupings for things like:
finance
marketing
product
daily
hourly
critical
expensive
The goal is to make it cheap to answer:
"What is the smallest safe subset I need to run?"
At large scale, folder structure alone isn't enough.
Every important model should have an owner:
fct_revenue
owner: finance-data
SLA: daily
criticality: high
You want to be able to answer:
This is where dbt metadata, groups, tags, documentation, and your broader data catalog can become valuable.
A single repo/project can comfortably contain a lot of models if the boundaries are good.
I'd consider splitting when you have real organizational or technical boundaries, such as:
Don't split simply because you've reached "500 models."
A badly structured five-project setup is harder to maintain than a well-structured 1,000-model project.
For a project growing from ~50 to ~1,000 models, I'd evolve it roughly like this:
| Scale | Structure |
|---|---|
| <50 models | Staging → intermediate → marts |
| 50–200 | Add domain subdirectories |
| 200–500 | Formalize ownership, CI selectors, contracts/tests, model interfaces |
| 500–1,000+ | Strong domain boundaries, dependency governance, selective builds, potentially multiple dbt projects |
The key is not adding organizational complexity merely because the project is large.
┌── Finance marts
│
Sources → Staging → Intermediate → Shared/core models
│
├── Marketing marts
│
└── Product marts
↓
BI / ML / Apps
And enforce five principles:
If you get those five right, the project can grow surprisingly large without becoming a maze.
Instead, create stable shared entities:
staging
↓
intermediate/core
↓
dim_customer
↓
finance
marketing
product
Think of high-value marts/models as internal APIs.
At minimum, test:
For example:
models:
- name: fct_orders
description: "One row per customer order."
columns:
- name: order_id
description: "Unique identifier for the order."
data_tests:
- not_null
- unique
- name: customer_id
data_tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
Don't try to test every column equally. Put the strongest guarantees around business-critical interfaces.
At hundreds or thousands of models, running the entire project for every PR becomes painful.
Use dbt's graph selection capabilities so that a change can trigger something like:
changed model
↓
downstream models
↓
relevant tests
rather than rebuilding unrelated domains.
Also consider tags/groupings for things like:
finance
marketing
product
daily
hourly
critical
expensive
The goal is to make it cheap to answer:
"What is the smallest safe subset I need to run?"
At large scale, folder structure alone isn't enough.
Every important model should have an owner:
fct_revenue
owner: finance-data
SLA: daily
criticality: high
You want to be able to answer:
This is where dbt metadata, groups, tags, documentation, and your broader data catalog can become valuable.
A single repo/project can comfortably contain a lot of models if the boundaries are good.
I'd consider splitting when you have real organizational or technical boundaries, such as:
Don't split simply because you've reached "500 models."
A badly structured five-project setup is harder to maintain than a well-structured 1,000-model project.
For a project growing from ~50 to ~1,000 models, I'd evolve it roughly like this:
| Scale | Structure |
|---|---|
| <50 models | Staging → intermediate → marts |
| 50–200 | Add domain subdirectories |
| 200–500 | Formalize ownership, CI selectors, contracts/tests, model interfaces |
| 500–1,000+ | Strong domain boundaries, dependency governance, selective builds, potentially multiple dbt projects |
The key is not adding organizational complexity merely because the project is large.
┌── Finance marts
│
Sources → Staging → Intermediate → Shared/core models
│
├── Marketing marts
│
└── Product marts
↓
BI / ML / Apps
And enforce five principles:
If you get those five right, the project can grow surprisingly large without becoming a maze.
For a large dbt project, optimize for **clear boundaries, predictable naming, reusable transformations, and ownership** rather than simply organizing files into lots of folders. dbt Labs' current guidance still centers on **staging → intermediate → marts**, with modular models and a DAG that makes dependencies…
For a large dbt project, optimize for clear boundaries, predictable naming, reusable transformations, and ownership rather than simply organizing files into lots of folders.
dbt Labs' current guidance still centers on staging → intermediate → marts, with modular models and a DAG that makes dependencies explicit.
A good default is:
models/
├── staging/
│ ├── salesforce/
│ │ ├── _sources.yml
│ │ ├── _models.yml
│ │ ├── stg_salesforce__account.sql
│ │ └── stg_salesforce__opportunity.sql
│ ├── stripe/
│ │ ├── _sources.yml
│ │ ├── _models.yml
│ │ └── stg_stripe__payment.sql
│ └── postgres/
│
├── intermediate/
│ ├── customers/
│ │ ├── int_customers__joined.sql
│ │ └── int_customers__enriched.sql
│ └── orders/
│
└── marts/
├── finance/
│ ├── fct_revenue.sql
│ └── dim_accounts.sql
├── marketing/
│ └── fct_campaign_performance.sql
└── product/
├── fct_product_usage.sql
└── dim_products.sql
One staging model per useful source table.
Keep it deliberately boring:
Avoid putting business logic here. The benefit is that changes to a raw source get handled in one place rather than replicated across dozens of downstream models. dbt specifically recommends staging as a modular foundation for this reason.
Naming:
stg_<source>__<entity>
stg_salesforce__account
stg_salesforce__opportunity
stg_stripe__payment
This is where complexity goes.
Use it for:
For example:
int_customers__joined
int_customers__deduplicated
int_orders__with_customer
int_orders__attributed
The important distinction is that intermediate models are implementation details, whereas marts are generally the stable interfaces consumed by analysts and downstream systems.
These are your business-facing data products.
Organize them by business domain:
marts/
├── finance/
├── marketing/
├── sales/
├── product/
└── operations/
Use names that communicate the grain and purpose:
fct_orders
fct_payments
fct_subscription_events
dim_customers
dim_products
dim_accounts
dbt Labs similarly uses fct_ and dim_ conventions for mart models.
This is one of the most useful rules at scale:
Staging → source-system organization
staging/
├── salesforce/
├── stripe/
├── zendesk/
└── postgres/
Marts → business-domain organization
marts/
├── finance/
├── marketing/
├── customer/
└── product/
That reflects two different questions:
"Where did this data come from?"
versus
"What business concept does this data represent?"
The dbt Project Evaluator also recommends source-specific staging directories and keeping model tests/documentation close to their models.
A common mistake is creating a project that looks organized in the filesystem but has a horrible DAG.
Aim for something conceptually like:
Raw sources
↓
Staging
↓
Intermediate
↓
Business entities
↓
Marts
↓
BI / ML / downstream applications
Avoid:
stg_customer ─────┐
├──> fct_revenue
stg_orders ───────┤
├──> dashboard_model
stg_payments ─────┘
stg_customer ─────────> another_mart
stg_orders ───────────> another_mart
stg_payments ─────────> another_mart
where every mart independently reconstructs the same customer/order/payment logic.
Instead, extract genuinely reusable transformations into intermediate or entity models. dbt Labs explicitly recommends abstracting code used across multiple models into its own model.
Scalability doesn't mean maximum modularity.
This is bad:
stg_orders
↓
int_orders_a
↓
int_orders_b
↓
int_orders_c
↓
int_orders_d
↓
fct_orders
if each intermediate model contains only 10 lines and is used once.
Prefer a new model when the transformation is:
Otherwise, excessive model decomposition makes the DAG harder to understand and debugging slower.
For a 20-model project, conventions are helpful.
For a 500-model project, they're essential.
Define things such as:
| Layer | Convention |
|---|---|
| Source | src_<source> |
| Staging | stg_<source>__<entity> |
| Intermediate | int_<entity>__<transformation> |
| Fact | fct_<entity> |
| Dimension | dim_<entity> |
| Report | rpt_<use_case> |
The dbt Project Evaluator can automatically identify violations of naming, directory, testing, and other structural conventions.
I'd put those checks into CI so architectural standards don't depend on code reviewers remembering them.
Don't create one gigantic:
models/schema.yml
containing documentation for hundreds of models.
Instead:
staging/
└── stripe/
├── _sources.yml
├── _models.yml
├── stg_stripe__customer.sql
└── stg_stripe__payment.sql
marts/
└── finance/
├── _models.yml
├── fct_revenue.sql
└── dim_accounts.sql
This reduces merge conflicts and makes it obvious where documentation for a model lives. The Project Evaluator specifically recommends keeping tests and documentation in the corresponding model directory.
One particularly powerful mindset for large projects:
Your marts are an API.
For example:
fct_orders
dim_customers
dim_products
should have:
Downstream dashboards shouldn't need to understand:
stripe
salesforce
postgres
zendesk
or know how customer identity is resolved.
They consume:
fct_orders
↓
BI
This dramatically reduces coupling between source systems and consumers.
At scale, folder structure alone isn't enough.
I'd assign ownership roughly like:
Finance
├── finance marts
├── revenue definitions
└── finance tests
Marketing
├── marketing marts
├── attribution logic
└── marketing tests
Product
├── product marts
├── engagement definitions
└── product tests
Then establish:
This prevents a 15-person analytics team from effectively having "everyone owns everything."
A reasonable starting point is:
staging → view
intermediate → view/table/ephemeral depending on use
marts → table/incremental
But don't blindly make every intermediate model ephemeral. Ephemeral models can reduce warehouse objects, but they also make debugging harder. Likewise, a frequently reused expensive intermediate transformation may be better as a table or view.
dbt's own guidance emphasizes staging models as views in many cases, while allowing materialization choices based on workload and cost.
For large fact tables, incremental models can become important—but introduce them because the workload warrants them, not simply because the project is large.
A useful distinction is:
Business entities
↓
Reporting models
For example:
marts/customer/
dim_customers.sql
fct_customer_orders.sql
marts/finance/
fct_revenue.sql
marts/reporting/
rpt_monthly_board_metrics.sql
rpt_sales_pipeline.sql
Don't let dashboard-specific SQL become the canonical representation of a business concept.
If three dashboards need "customer lifetime value," you generally want one governed definition rather than:
dashboard_a_customer_ltv
dashboard_b_customer_ltv
dashboard_c_customer_ltv
A large dbt project should make it easy to run things like:
dbt build --select state:modified+
or domain-specific subsets such as:
dbt build --select marts.finance+
That becomes much more practical when:
Your CI should generally avoid rebuilding the entire warehouse for every pull request.
For a large organization, eventually you may have a case for multiple projects:
finance-dbt
product-dbt
marketing-dbt
But don't do this simply because you reached 200 models.
A well-organized monorepo can go surprisingly far.
Consider splitting when you have real organizational or dependency boundaries—for example, independently managed domains, separate deployment lifecycles, or a project becoming genuinely difficult to operate as one unit.
For a mature ~500-model project, I'd start around this shape:
analytics/
│
├── dbt_project.yml
├── packages.yml
│
├── models/
│ │
│ ├── staging/
│ │ ├── salesforce/
│ │ ├── stripe/
│ │ ├── postgres/
│ │ └── zendesk/
│ │
│ ├── intermediate/
│ │ ├── customer/
│ │ ├── orders/
│ │ ├── subscriptions/
│ │ └── marketing/
│ │
│ └── marts/
│ ├── finance/
│ ├── sales/
│ ├── marketing/
│ ├── customer/
│ └── product/
│
├── macros/
├── tests/
├── snapshots/
├── seeds/
└── analyses/
And I'd enforce these architectural rules:
Raw → staging → intermediate → marts
Staging is source-oriented.
Marts are domain-oriented.
Business logic doesn't live in staging.
BI consumes marts, not raw/intermediate models.
Reusable logic gets its own model.
Every model has an owner, description, and appropriate tests.
Naming and directory conventions are CI-enforced.
The DAG should be understandable without opening every SQL file.
That's the combination that tends to make a dbt codebase scale—not any particular folder hierarchy by itself. dbt's recent guidance similarly emphasizes layered modeling, modularity, and keeping the DAG understandable as projects grow.
If you're starting from an existing messy 200–1000 model dbt project, the strategy is different: I would not reorganize everything at once. I'd first map the DAG, identify duplicate business logic and high-centrality models, establish naming/ownership rules, and then migrate domain by domain.
Structuring a large dbt (data build tool) project requires `moving away from a single, chaotic folder of models and adopting a modular, layered architecture` . This ensures maintainability, clear lineage, and scalability as your team and data warehouse…
Structuring a large dbt (data build tool) project requires moving away from a single, chaotic folder of models and adopting a modular, layered architecture . This ensures maintainability, clear lineage, and scalability as your team and data warehouse grow.
Here is a proven framework for structuring a large dbt project:
stg) — The foundation layer where you connect to your raw source tables. Models here should have a 1:1 relationship with raw sources. Focus on light cleaning, renaming columns to a consistent convention, casting data types, and handling simple derivations. No business logic or heavy joins belong here.int) — Optional but highly recommended for complex projects. This is where the heavy lifting happens—joins, un-pivoting, aggregates, and business logic that is shared across multiple data marts. Intermediate models should combine staging models, but they should not be exposed directly to end-users or BI tools.marts) — The final consumption layer organized by business domains or entities (e.g., finance, marketing, core ). Models here represent business concepts (like dim_customers or fct_orders ) and are optimized for BI tools and stakeholder queries.Key Organizational Best Practices
marts and intermediate folders, break your models down by business domain (e.g., marts/finance/, marts/marketing/ ) rather than technical type.stg_ for staging models.int_ for intermediate models.dim_ for dimension tables.fct_ for fact tables.dbt_project.yml Configurations: Apply folder-level configurations (like materialized views, tags, or meta properties) in your dbt_project.yml rather than repeating {{ config(...) }} macros inside every single model file.unique and not_null tests, but relationship tests and custom data tests. Maintain your schema.yml files close to the models they document.Would you like me to dive deeper into:
Structuring a large **dbt** (data build tool) project requires a modular, layered architecture to keep code clean, scalable, and easy to maintain. The industry standard approach organizes models into distinct functional layers, moving progressively from raw data to business-ready…
Structuring a large dbt (data build tool) project requires a modular, layered architecture to keep code clean, scalable, and easy to maintain. The industry standard approach organizes models into distinct functional layers, moving progressively from raw data to business-ready reporting.
Organize your models/ directory into clear functional subdirectories (schemas/layers):
models/staging/ or staging/)
stg_<source_name>__.sql (e.g., stg_stripe__customers.sql).models/intermediate/ or intermediate/)
int__<verb>_.sql (e.g., int_orders__joined_with_payments.sql).models/marts/ or marts/)
<domain>__fct_<name>.sql or <domain>__dim_<name>.sql (e.g., finance__fct_revenue.sql, core__dim_customers.sql).sources.yml) : Always define your raw data inputs in a _sources.yml file within your staging folders. Use the {{ source('source_name', 'table_name') }} macro in your staging models instead of hardcoding raw table names.{{ config(materialized='ephemeral') }} . This compiles them as CTEs in the downstream models rather than cluttering your data warehouse with physical intermediate tables/views.schema.yml (or domain-specific yaml files) right next to the models they document. Define primary key tests (unique and not_null ) for every single dimension and fact table.finance, daily, hourly ) to models so you can run subsets of your DAG efficiently using dbt run --select tag:finance.stg_ ), intermediate (int_ ), dimensions (dim_ ), and facts (fct_).If you'd like to dive deeper, let me know:
I can share a custom directory tree or specific configuration examples for your setup.
A large dbt project stays maintainable when the structure reflects **data flow, ownership, and reuse** rather than just mirroring every source system or dashboard. A common scalable pattern is: 1. **Staging → clean source-aligned data** 2. **Intermediate → reusable business logic**
A large dbt project stays maintainable when the structure reflects data flow, ownership, and reuse rather than just mirroring every source system or dashboard. A common scalable pattern is:
This layered approach is widely used because it limits duplication and makes lineage easier to understand.
A practical structure:
dbt_project/
├── models/
│ ├── staging/
│ │ ├── stripe/
│ │ │ ├── stg_stripe__customers.sql
│ │ │ ├── stg_stripe__payments.sql
│ │ │ └── stripe.yml
│ │ └── salesforce/
│ │ ├── stg_salesforce__accounts.sql
│ │ └── salesforce.yml
│ │
│ ├── intermediate/
│ │ ├── customers/
│ │ │ ├── int_customers__unified.sql
│ │ │ └── customers.yml
│ │ └── revenue/
│ │ └── int_revenue__monthly.sql
│ │
│ └── marts/
│ ├── finance/
│ │ ├── fct_revenue.sql
│ │ └── dim_accounts.sql
│ ├── marketing/
│ │ └── dim_campaigns.sql
│ └── product/
│ └── fct_usage.sql
│
├── macros/
├── snapshots/
├── seeds/
├── tests/
├── analyses/
└── dbt_project.yml
Staging should answer: "How do I make raw data consistent?"
Examples:
stg_stripe__payments
stg_salesforce__accounts
stg_app__events
Responsibilities:
Avoid:
A staging model should usually represent one source table.
Intermediate models answer: "How do we transform clean data into reusable business concepts?"
Examples:
int_customer__lifetime_value
int_orders__with_returns
int_subscription__status_history
Good uses:
Avoid exposing intermediate models directly to analysts unless there is a strong reason.
Marts answer: "What datasets do consumers actually use?"
Typical domains:
marts/
├── finance/
├── marketing/
├── sales/
├── product/
└── operations/
Common naming:
dim_customer
dim_product
fct_orders
fct_revenue
Dimensions describe entities; facts describe measurable events.
A large project should make model purpose obvious from the name.
Example:
| Layer | Pattern | Example |
|---|---|---|
| Staging | stg_<source>__<entity> | stg_shopify__orders |
| Intermediate | int_<entity>__<transformation> | int_orders__enriched |
| Fact | fct_<process> | fct_sales |
| Dimension | dim_<entity> | dim_customer |
Avoid names like:
final_orders.sql
customer_model.sql
new_table.sql
Put documentation and tests near the models they describe:
marts/
└── finance/
├── fct_revenue.sql
└── finance.yml
Example:
models:
- name: fct_revenue
description: Monthly revenue by customer
columns:
- name: revenue_id
tests:
- unique
- not_null
This improves discoverability and reduces forgotten documentation.
dbt_project.yml for defaultsAvoid repeating configs everywhere.
Example:
models:
analytics:
staging:
+materialized: view
intermediate:
+materialized: ephemeral
marts:
+materialized: table
Then override only when needed:
{{ config(
materialized='incremental',
unique_key='order_id'
) }}
Common pattern:
For a large team, metadata matters.
Useful additions:
meta:
owner: finance_team
contains_pii: true
criticality: high
Then use it for:
Bad:
models/
├── customers.sql
├── orders.sql
├── revenue.sql
├── ...
(500 files)
Hard to navigate and own.
Bad:
fct_sales.sql
|
raw_orders
Better:
fct_sales
|
int_orders
|
stg_orders
|
raw_orders
A 1,000-line mart becomes impossible to test. Move reusable pieces into intermediate models.
A single dbt project can work for hundreds of models, but very large organizations often move toward domain ownership:
dbt_finance/
dbt_marketing/
dbt_product/
with shared packages or cross-project dependencies.
For a mature dbt project:
The filesystem should let a new engineer answer: "What is this model, who owns it, and what breaks if I change it?" without opening the SQL.
Structuring a large **dbt** (data build tool) project requires `moving away from a single, chaotic directory of models and adopting a modular, tiered architecture` . This mirrors software engineering best practices by separating concerns, enforcing data contracts, and making lineage easy to…
Structuring a large dbt (data build tool) project requires moving away from a single, chaotic directory of models and adopting a modular, tiered architecture . This mirrors software engineering best practices by separating concerns, enforcing data contracts, and making lineage easy to trace.
Here is how to structure a large-scale, maintainable dbt project:
models/staging/) : The first stop for raw data. Here, you connect to your sources. Models in this layer should have a 1-to-1 relationship with source tables.
models/staging/stripe/, models/staging/salesforce/).models/intermediate/) : Optional, but crucial for large projects. This is where you clean, prep, and join data before building final business entities.
models/intermediate/finance/, models/intermediate/marketing/).models/marts/) : The final destination. This layer contains business-ready, highly polished data models (dimensional models like facts and dimensions, or wide analytical tables) consumed by BI tools, stakeholders, or reverse-ETL pipelines.
models/marts/core/, models/marts/finance/, models/marts/product/).sources/ * : Keep source definitions (and freshness configs) clean, either in a dedicated _sources.yml file per staging directory or a centralized folder.seeds/ * : Static CSV files managed by dbt (e.g., country codes, mapping tables).macros/ * : Reusable SQL or Jinja snippets. Keep them organized by function (e.g., generic tests, custom SQL operations).snapshots/ * : For implementing Type 2 Slowly Changing Dimensions (SCD) on mutable source tables.not_null and unique on primary keys in your marts, and define descriptions in schema.yml files alongside your models.--select tag:hourly ) and groups/access controls if using dbt Mesh for multi-project architectures.If you'd like to dive deeper, let me know:
I can provide a visual folder tree template or specific naming convention guidelines tailored to your setup.
For a **large dbt project**, optimize for three things above all: **clear ownership, predictable lineage, and small blast radius when something changes**. A good default is the classic **staging → intermediate → marts** architecture. dbt itself recommends these layers as progressively higher-level transformations.…
For a large dbt project, optimize for three things above all: clear ownership, predictable lineage, and small blast radius when something changes.
A good default is the classic staging → intermediate → marts architecture. dbt itself recommends these layers as progressively higher-level transformations.
A structure like this scales well:
models/
├── staging/
│ ├── stripe/
│ │ ├── _stripe__sources.yml
│ │ ├── _stripe__models.yml
│ │ ├── stg_stripe__customers.sql
│ │ └── stg_stripe__payments.sql
│ ├── salesforce/
│ │ ├── _salesforce__sources.yml
│ │ └── ...
│ └── shopify/
│ └── ...
│
├── intermediate/
│ ├── customers/
│ │ ├── int_customers__enriched.sql
│ │ └── int_customers__deduped.sql
│ ├── orders/
│ │ └── int_orders__with_customer.sql
│ └── ...
│
└── marts/
├── finance/
│ ├── dim_customers.sql
│ ├── fct_payments.sql
│ └── _finance__models.yml
├── sales/
│ ├── dim_products.sql
│ ├── fct_orders.sql
│ └── _sales__models.yml
└── marketing/
└── ...
The important distinction is:
This prevents business logic from leaking into source-specific models and makes the DAG tell a coherent story.
For example:
raw.stripe.customers
↓
stg_stripe__customers
↓
int_customers__enriched
↓
dim_customers
A staging model should establish your canonical representation of the source:
select
id as customer_id,
email,
created_at,
cast(is_deleted as boolean) as is_deleted
from {{ source('stripe', 'customers') }}
Then downstream models don't have to know that Stripe called the field id, or that its source data type was inconvenient.
This also gives you a useful boundary: source-system changes are absorbed in staging rather than propagated throughout the project.
This is one of the most useful mental models for a big project.
Your mart models are effectively the public API of your warehouse.
That means:
For example:
fct_orders
grain: one row per order
dim_customers
grain: one row per customer
fct_order_items
grain: one row per order item
Make the grain explicit in model documentation. Grain mistakes are among the most damaging problems in analytics projects.
Once you have hundreds of models, a purely technical organization becomes painful.
Prefer:
marts/
├── finance/
├── sales/
├── marketing/
├── product/
└── operations/
rather than:
marts/
├── tables/
├── aggregates/
├── joins/
└── reports/
The former lets someone answer "where does the revenue model live?" immediately.
Folders should describe the business problem/domain the model serves, rather than which engineer happened to create it.
Don't create one gigantic schema.yml containing thousands of models.
Instead:
staging/stripe/
_stripe__sources.yml
_stripe__models.yml
marts/finance/
_finance__models.yml
marts/sales/
_sales__models.yml
Folder-level YAML is a useful compromise: documentation and tests remain discoverable without creating one YAML file per model.
At minimum, important production models should document:
I'd enforce rules like:
staging → sources only
intermediate → staging + other intermediate
marts → intermediate + staging
Avoid:
staging → marts
marts → staging
sales → marketing → sales
especially when these dependencies create cycles of business logic.
A useful rule is:
Business logic flows downstream; it doesn't flow sideways and backward.
There will be exceptions, but requiring an explicit reason for them keeps the DAG sane.
A common failure mode is turning this:
source → transformation → mart
into:
source → stg → int_1 → int_2 → int_3 → int_4 → mart
That's technically organized but operationally terrible.
Create an intermediate model when it provides something meaningful:
If a transformation is tiny and used once, it may belong directly in the downstream model.
Don't let every developer independently choose table, view, incremental, or ephemeral.
A reasonable starting policy is:
| Layer | Default |
|---|---|
| Staging | view |
| Intermediate | view / ephemeral |
| Marts | table |
| Large facts | incremental |
Then override based on actual workload.
For example:
models:
my_project:
staging:
+materialized: view
intermediate:
+materialized: view
marts:
+materialized: table
Large append-heavy fact tables are good candidates for incremental models, but don't automatically make everything incremental—the complexity and correctness requirements aren't free.
At minimum, important entities should have tests around:
primary key → unique + not_null
foreign key → relationships
categorical fields → accepted_values
source → freshness
But don't stop at generic tests.
For example, if:
fct_orders
grain = one row per order
you should have a test that actually verifies that assumption.
Put tests and documentation near the model they describe so they're difficult to forget when the model changes. dbt's project-evaluator guidance specifically emphasizes keeping model tests/documentation organized with their corresponding directories.
For a large organization, I'd distinguish between:
internal implementation models
and
stable consumer-facing models.
Your marts/semantic-facing models are where contracts become especially valuable. Consumers should be able to depend on:
fct_orders.order_id
fct_orders.customer_id
fct_orders.order_total
fct_orders.order_status
without caring whether you internally rewrote five intermediate models.
That dramatically reduces the blast radius of refactoring.
Running the entire project for every pull request doesn't scale.
A mature setup typically has:
PR
↓
lint / parse
↓
modified models + relevant parents
↓
tests
↓
merge
↓
production build
Use state-aware/slim CI where appropriate so a developer changing one model doesn't have to rebuild thousands of unrelated models.
Once you have hundreds or thousands of models, conventions shouldn't live only in a wiki.
Automate checks for things such as:
dbt-project-evaluator is one option specifically designed to evaluate structural conventions in dbt projects.
For most large teams, I'd start here:
dbt_project/
│
├── models/
│ ├── staging/
│ │ ├── stripe/
│ │ ├── salesforce/
│ │ └── shopify/
│ │
│ ├── intermediate/
│ │ ├── customers/
│ │ ├── orders/
│ │ └── products/
│ │
│ └── marts/
│ ├── finance/
│ ├── sales/
│ ├── marketing/
│ └── product/
│
├── macros/
├── tests/
├── snapshots/
├── seeds/
├── analyses/
├── dbt_project.yml
└── README.md
And enforce these principles:
Source systems → staging → reusable business logic → domain marts → consumers
The biggest scalability win isn't a clever directory tree. It's establishing clear boundaries so that developers know where logic belongs, what they can depend on, and what they are allowed to change without breaking everyone downstream.