DBTStructure & Production

Project Structure: Staging → Intermediate → Marts

How analytics engineers turn raw warehouse tables into trusted models — pick a topic on the left and its full breakdown loads here: the mental model, the compiled SQL dbt actually issues, live runs you can drive yourself, and the failure modes that quietly ship wrong numbers.

18 min readTopics chapter readerLevel · Medium
01 · Open the project you already have

A Project You Can Hold In Your Head

A layer is not a folder. It is a promise about grain and about who is allowed to change it. Staging promises one row per source entity from exactly one source. Intermediate promises to declare its grain, which may differ from its inputs’. Marts promise one row per business entity or event, with a human on the other end. The folder is just where you keep the promise.

4 min · Topic 1 of 9

Eleven chapters in, Wavelength has 22 graph nodes, five marts in one flat folder, and an intermediate layer with exactly one model in it. Nothing is broken. Every run is green — except one test, and it is green for the wrong reason.

Click around the x-ray below before reading anything else. Switch the lens, switch the view, and find out for yourself what a new hire would find on their first morning.

the project census — the four counts this chapter keeps honest22 graph nodes · 19 non-exposure · 14 buildable · 23 manifest nodes
graph nodes22everything dbt ls walks
non-exposure19exposures create no object
buildable14has a relation for dbt build
manifest nodes23+1 analysis, never built
overlay a lens — the project does not change, what you can see does
sources (5)
wavelength_app.showssourceswavelength_app.episodessourceswavelength_app.listenssourceswavelength_app.subscriptionssourcesstripe.paymentssources
seeds (2)
plan_namesseedspromo_codesseeds
staging (5)
stg_showsstagingstg_episodesstagingstg_listensstagingstg_subsstagingstg_stripe__paymentsstaging
snapshots (1)
snap_subscriptionssnapshots
intermediate (1)
int_listens_enrichedintermediate
marts (5)
dim_showmartsfct_listensmartsfct_subscription_revenuemartsagg_daily_listensmartsmart_engagement_summarymarts
exposures (3)
weekly_listening_dashboardexposureschurn_risk_modelexposuresfinance_revenue_extractexposures
findings — click one to read it and to switch the lens that shows it5 marts · 4 prefix schemes
Note what the DAG cannot tell you and the tree can: dim_show reads stg_shows and does nothing else to it. That is not laziness. A mart is a naming and access boundary as much as a transformation boundary — Section 5 defends it before somebody deletes it.
Core mental model

A layer is not a folder. It is a promise about grain and about who is allowed to change it. Staging promises one row per source entity from exactly one source. Intermediate promises to declare its grain, which may differ from its inputs’. Marts promise one row per business entity or event, with a human on the other end. The folder is just where you keep the promise.

Why it matters

Folder layout looks like a taste question, and it is not. It is the question of where the row count is allowed to change. In a 437-line script the grain changes six times inside one file and none of those six moments has a name — so none of them can have a test, and none of them can be selected in CI. Split the same logic across staging, intermediate and marts and every one of those moments becomes a named, queryable, testable relation. That is the whole engineering argument for layering, and it has nothing to do with tidiness. Everything else in this chapter is a consequence of it.

grain
What one row of a table means — one payment, one subscription version, one show — expressed as the exact set of columns that is unique per row. Every layer boundary in this chapter is a place where the grain is allowed to change, and every one of those places should be named.
staging
One model per source table, 1:1 with it, reading exactly one source() and joining nothing. Chapter 5 built this layer and owns the argument for it; this chapter only defends the boundary against models that want to sneak in.
intermediate
Models that reshape for reuse: joins, pivots, aggregations to a join grain. Named after the verb they perform, not exposed to consumers. This project has exactly one, which is Section 1’s second finding.
mart
The consumable layer. dbt Labs’ marts guide defines it by grain rather than by prefix: a mart “represents a specific entity or concept at its unique grain”. fct_ and dim_ are a style layered on top of that sentence, and Section 7 shows two dbt Labs artifacts disagreeing about them.
root model
A model that calls neither ref() nor source(). It builds fine and is invisible to lineage, to state:modified+, and to Chapter 11’s +exposure:* traversal. dbt-project-evaluator lints it as fct_root_models. Section 6 takes one apart.
user_id vs listener_id
Wavelength’s app calls this account user_id (stg_listens, values like u_4); its billing system calls the SAME account listener_id (subscriptions, snap_subscriptions, values like L-9001). One entity, two systems, two names — and the raw values do not line up, so a join needs a resolved key, not a rename. stg_subs is the one relation that has ever carried both on the same row, because wavelength_app.subscriptions carries user_id as a foreign key back to the app account alongside its own listener_id — declared in sources.yml this chapter. That is where the crosswalk lives, and Section 3 shows it.
The three promises, and what each one buys the next reader
LayerThe promise about grainWho may change itWhat you can assert
stagingOne row per source entity. One source, no joins.Anyone — freely. Nothing downstream depends on its history.row count equals the source’s row count; unique on the natural key
intermediateI declare my grain, and it may differ from my inputs’.The owner of the reuse. Two consumers means two people to ask.a uniqueness test on whatever grain the model chose
martsOne row per business entity or event, and a human consumes me.Nobody, casually. There are exposures pointing at this.grain, plus every business rule you can name
snapshotOne row per entity per VERSION. Its history is irrecoverable.Effectively nobody. You cannot re-derive what you did not capture.at most one open row per key; non-overlapping intervals

The fourth row is why Section 4 refuses to re-point stg_subs at snap_subscriptions. A snapshot is not a staging model with extra columns; it is a different grain with a different change-control regime, and giving it a stg_ name tells every future reader the opposite of both.

Common mistake

Treating project structure as a style question to settle after the models work. By then the structure IS the models. Moving a model between folders changes its config inheritance, its schema on some projects, and every path in every properties file that mentions it — and it is a rename to everything outside the graph that reads the warehouse directly. The cheapest moment to place a model is before it has consumers, and that moment lasts about a week.

Reading “green run” as “correct project” — or a passing transcript as a test’s history. This project has been carrying a uniqueness test on the wrong grain since Chapter 7 — it has been failing since the day it was written, because S-101’s second payment (pay_006) was already in the data Chapter 4 published. Chapter 11’s docs-tour transcript renders it as PASS; that is a recorded erratum in Chapter 11, not a fact about this test’s history. Section 2 shows the failure and names the fix. A transcript tells you what a run captured, not what an assertion has actually been asserting.

Better habit

When you inherit a project, open the folder tree and the DAG side by side. Anything in the tree with no counterpart in the DAG, or vice versa, is where the interesting problems are.

Ask of every model: what does one row mean, and where is that written down? If the answer is “in someone’s head”, you have found the next test to write.

Place a new model in a layer on the day you create it, and say out loud which promise you are making. It is a thirty-second conversation now and a migration later.

Grain is the Data Modeling KB’s, and it is worth reading before you draw a boundary

Grain is what one row of a table means — one payment, one subscription version, one show — and the grain is exactly the set of columns that is unique per row. Join a table to a finer-grained one and the coarse rows repeat, so any sum taken afterwards is inflated by the fan factor; the cures are to aggregate before joining, or to sum only measures that already live at the joined grain. Declaring grain, additivity, mixed grain, and the fan and chasm traps by name are the Data Modeling KB’s, and it is worth reading before you draw a layer boundary. This chapter uses those ideas to decide which MODEL a multiplying join lives in and which LAYER the fix belongs to — never to re-derive the theory.

Grain: what one row means — Data Modeling KB
Medallion, in two sentences, and then never again

Raw and bronze are roughly the sources Chapter 5 declared, staging plus intermediate is roughly silver, and marts are roughly gold — but the mapping is loose and the names are not interchangeable, because medallion is about data-quality tiers in storage while staging → intermediate → marts is about how code is organised inside one warehouse. The Data Pipeline KB owns bronze/silver/gold properly; this chapter does not teach it.

The data pipeline lifecycle — Data Pipeline KB
The clock, the scale, and what this chapter does NOT change

This chapter runs at 2026-07-27 09:00 UTC, after Chapter 10’s snapshot runs and Chapter 11’s docs work. It adds exactly one node — dim_subscription — taking the graph from 22 to 23, non-exposure nodes from 19 to 20, buildable nodes from 14 to 15, and manifest nodes from 23 to 24. It adds ZERO warehouse rows. The 1,000-row dataset in Sections 3 and 6 is a hypothetical, scaled-up stand-in for Wavelength’s real tables — Chapter 10’s 10-row snapshot and Chapter 9’s ~15 real listens — invented because fan-out is invisible at that scale, for the seven days ending 2026-07-18, and it is captioned as hypothetical every time it appears. Chapter 3’s 1.21 / 0.25 hours, Chapter 4’s 5 rows and $184.00, and Chapter 7’s 9-row stg_episodes all stand.

Remember this

A layer is a promise about grain and about who may change it — and the convention’s real product is that every place the row count is allowed to change becomes a named, queryable, testable relation.