Each layer has 1 job
| Layer | Prefix | Reads from | Does | Built as |
|---|---|---|---|---|
| Staging | stg_ | source() only | rename, cast, light cleaning, 1 model per source table | view |
| Intermediate | int_ | staging and other intermediate models | joins and aggregations that change the grain | view or ephemeral |
| Marts | fct_, dim_ | intermediate and staging | the tables analysts query, named for the business | table |
Each arrow is a ref() or source() call. Rows flow left to right and never back into an earlier layer.
Staging: one model per source table
A staging model reads 1 source table through source(), renames its columns to the names the project uses, casts types and stops; joins, aggregations and business rules all happen in later layers. The file name is stg_ followed by the source name and the table name, with a doubled separator between the two, so stg_billing__subscriptions can only mean one thing. Everything downstream selects subscription_id and never id, which is why the billing team can rename its column and the project changes in 1 file.
Intermediate: where the grain changes
Marts: what the business asks for
- ▸Staging: select from exactly 1 source() call. Rename, cast, nothing else.
- ▸Intermediate: ref() staging or intermediate models. Joins and aggregations happen here.
- ▸Marts: ref() intermediate or staging models. Never source().
- ▸Nothing reads upward: a staging model never refs an intermediate model or a mart.
models/intermediate, name it int_ plus what it reads, a doubled separator, and what a row is, and the mart shrinks back to a join.What one row is
A join that multiplies
| order_id | customer_id | order_total |
|---|---|---|
| 1 | 101 | 30.00 |
| 2 | 102 | 12.00 |
| 3 | 101 | 45.00 |
| item_id | order_id | sku | quantity |
|---|---|---|---|
| 1 | 1 | A | 1 |
| 2 | 1 | B | 2 |
| 3 | 2 | C | 1 |
| 4 | 3 | A | 1 |
| 5 | 3 | D | 3 |
| order_id | item_id | customer_id | order_total |
|---|---|---|---|
| 1 | 1 | 101 | 30.00 |
| 1 | 2 | 101 | 30.00 |
| 2 | 3 | 102 | 12.00 |
| 3 | 4 | 101 | 45.00 |
| 3 | 5 | 101 | 45.00 |
| customer_id | revenue the join reports | true revenue |
|---|---|---|
| 101 | 150.00 | 75.00 |
| 102 | 12.00 | 12.00 |
Change the grain in its own model
Proving the grain
- ▸1 to 1 (orders to per-order item counts): the grain is unchanged.
- ▸Many to 1 (orders to customers): the grain is the many side, unchanged. Customer columns repeat, which is fine, and customer measures must not be summed here.
- ▸1 to many (orders to order lines): the grain becomes the many side. Aggregate the many side first to keep the one side's grain.
- Name the grain in the model name and in its first comment line
- Aggregate the many side to the one side's grain before joining
- Compare count(*) with count(distinct key) after every new join
- Use coalesce after a left join so a missing match reads 0
- Sum a measure from the one side of a 1 to many join
- Mix 2 grains in 1 model
- Trust a query because its numbers look plausible
- Patch fanout with select distinct, which hides the cause
Data that lives in the repository
| plan_code | plan_tier | monthly_price_usd |
|---|---|---|
| free | free | 0.00 |
| basic | paid | 9.00 |
| plus | paid | 14.00 |
| family | paid | 19.00 |
seeds/plan_codes.csv: 1 header line and 4 data lines, saved as plain text beside the models.
| Command | What happens to plan_codes |
|---|---|
| dbt seed | the table is created or replaced with the file's 4 rows |
| dbt build | the seed is loaded first, then every model that refs it |
| dbt run | the seed is untouched; models read whatever rows the table already holds |
Pinning the types
Without this file dbt infers each column's type from the values it sees. A column of postal codes like 02134 becomes an integer and loses its zero; a price column can land as a floating point type, where a value like 19.99 has no exact binary form and a sum over thousands of rows drifts by fractions of a cent. Declaring column_types under the seed's config makes every load produce the same columns whatever the values, and a reviewer can see the types in the same pull request as the data.
Reading a seed
Seed or source
- Typed by a person and changed through pull requests
- Hundreds of rows, a few thousand at most
- No system owns it; the project is its home
- Versioned with the code that reads it
- Owned and written by another system
- Loaded into the warehouse by something other than dbt
- Any size, from 10 rows to 10 billion
- Changes without a commit, so dbt only reads it
A model dbt never builds
The SQL dbt sends
| Materialization | What dbt sends for the node | What the warehouse holds after | What a ref() to it becomes |
|---|---|---|---|
| view | create view | a saved query | the view's name |
| table | create table as | the rows | the table's name |
| ephemeral | nothing of its own | nothing | a CTE pasted into the caller |
What that changes
Ephemeral or a warehouse object
- Logic read by 1 or 2 models
- A filter or a small aggregation too light to deserve a warehouse object
- A schema you want to show only the tables analysts should open
- Logic refd by many models, which would each recompute it
- Rows you need to inspect while debugging
- Heavy work, where a table stores the result once per build
A table that forgets
- Holds the present state, 1 row per key
- Rebuilt from scratch each run
- Cannot answer what a row said last week
- Holds every version of each row dbt has seen
- Grows each run by the rows that changed
- Answers what a row said at any moment since the first run
How dbt snapshot compares and writes rows
| Column | What it holds |
|---|---|
| dbt_valid_from | the updated_at the version was taken from, when the version began |
| dbt_valid_to | the updated_at of the version that replaced it, null while current |
| dbt_updated_at | the source row's updated_at at the time of the run |
| dbt_scd_id | a hash of the key and updated_at, 1 per version |
selectsubscription_id,user_id,plan_code,status,dbt_valid_fromfrom {{ ref('snap_subscriptions') }}where dbt_valid_to is null
| subscription_id | plan_code | status | dbt_valid_from | dbt_valid_to |
|---|---|---|---|---|
| 1 | basic | active | 2026-02-10 08:00:00 | null |
| 2 | basic | active | 2026-02-14 12:30:00 | 2026-03-02 09:00:00 |
| 2 | plus | active | 2026-03-02 09:00:00 | null |
| 3 | plus | active | 2026-02-20 17:45:00 | null |
snap_subscriptions after run 2: 4 rows for 3 subscriptions, and the only closed row is the one that changed.
Reading history back
In the layers, a snapshot reads the source, so it sits beside staging, and an intermediate model reads it with ref('snap_subscriptions'), the way it would read a staging model. Marts that need history read that intermediate model; marts that need only the present can keep reading stg_billing__subscriptions. dbt snapshot runs only snapshots, and dbt build runs them in DAG order alongside seeds and models, so the snapshot refreshes before anything that refs it.
- ▸Snapshot the source relation, not a staging view: history should hold what the system said, before any rename or cast the project might later change.
- ▸updated_at must move whenever a column you care about changes. A row that changes without touching updated_at is a change the snapshot never sees.
- ▸A value held only between 2 runs is never seen: a row changed and changed back gets a new version holding the old value. The run schedule sets the history's resolution.
- ▸dbt never drops a snapshot table on its own, and nothing can rebuild lost history from a source that keeps only the present.
> Netflix launched an ad-supported plan in November 2022, and members moved between plans for months afterwards. A finance analyst wants monthly revenue by plan for the year around the launch, built from a billing table that overwrites each member's row whenever the plan changes.
dbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.
github.com/dbt-labs/dbt-core
dbt and the dbt logo are trademarks of dbt Labs, LLC.
Layer a dbt project so each model has one job, one grain, and the right place to live.
- Category
- dbt
- Difficulty
- intermediate
- Duration
- 20 minutes
- Challenges
- 6 hands-on challenges
Topics covered: Staging, Intermediate, Marts, One Grain per Model, Seeds for Small Lookups, Ephemeral Models, Snapshots Keep History
Lesson Sections
- Staging, Intermediate, Marts (concepts: Model layers and grain)
Each layer has 1 job A project with 1 source table and 2 marts can read the source from each mart and stay readable. A project with 40 source tables and 60 marts cannot: every mart repeats the same renames and casts, and when the billing system renames a column, every mart that touches it breaks on the same morning. Layering splits the work into 3 kinds of model. Each kind is allowed one job, so any change lands in 1 file. Take 3 marts that read billing.subscriptions directly. The first casts st
- One Grain per Model (concepts: Model layers and grain)
What one row is The grain of a model is what 1 row stands for. fct_orders holds 1 row per order, fct_order_items 1 row per line on an order, int_plays__daily_by_user 1 row per user per day. Write the grain down before the select, because every join and every sum in the model is correct or wrong only relative to it. A model with 2 grains mixed together has no grain, and no query on it can be trusted. A join that multiplies Take a shop with 3 orders and 5 order lines. Each order carries its own to
- Seeds for Small Lookups (concepts: Sources and seeds)
Data that lives in the repository Some tables have no system behind them: a mapping of 4 plan codes to a tier and a monthly price, 12 country codes grouped into 3 sales regions, the 9 internal test accounts every mart must exclude. Nobody loads them, somebody types them, they change a few times a year, and each change should go through the same review a model goes through. dbt calls such a file a seed: a CSV saved under seeds/ that dbt loads into the warehouse as a table, and that models ref() l
- Ephemeral Models (concepts: Materializations)
A model dbt never builds Materialized as a view or a table, a model becomes an object in the warehouse. Materialized as ephemeral, it becomes nothing there. dbt keeps its SQL, and when another model refs it, dbt pastes that SQL into the referencing model as a CTE and sends the combined statement. The ephemeral model exists in the project files and in the lineage, and never in the schema. Here the intermediate model that section 1 asked for settles subscriptions to 1 row per user. The group by se
- Snapshots Keep History (concepts: Snapshots)
A table that forgets billing.subscriptions holds 1 row per subscription, and the billing system updates that row in place. On March 1, subscription 2 reads plan_code basic. At 09:00 on March 2 the user upgrades, the row reads plus, and updated_at moves forward. stg_billing__subscriptions is a view over that table, so it shows plus too, and nothing in the project says basic was ever there. A revenue-by-plan mart built on March 3 reports March 1 as if subscription 2 had been on plus all along. A m