The config is the project's contract with dbt
Session b1 ran the whole project with one command and read the output. The reason one command was enough is that the project already told dbt everything it needed: which folder holds models, which connection to use, what each layer should be materialized as, which schema each layer lands in, and which dates the business questions mean by "the last fortnight". None of that lives in a model. All of it lives in two files you can read in three minutes.
This is the shortest session in the builder track and the one that pays off most often, because almost every "why did dbt do that" question is answered here. A model built as a table when you wanted a view, an object that appeared in the wrong schema, a colleague whose build overwrote yours, a date that means one thing in three models: all config, all fixable in one place.
Two files, two jobs 7 min live
The separation between these two files is the single best design decision in dbt's configuration model, and it is the one people skim past. One file describes the project and is committed to git for everyone to review. The other describes a machine, a warehouse and a set of credentials, and is deliberately not in the repository at all.
Livedbt_project.yml, line by line4 min▶
This is the whole file for the Bazaar project. Nothing has been trimmed for the slide.
| Line | The decision it makes |
|---|---|
name: bazaar | the project's identity. It becomes the top key under models:, which is why the config block reads models: bazaar: staging:. Rename the project and you must rename that key too |
version: "1.0.0" | your project's version, not dbt's. Useful when the project is installed as a package by another project, ignorable otherwise |
profile: bazaar | a pointer, by name, to an entry in profiles.yml. This is the whole seam between code and environment: the project asks for a profile called bazaar and does not care what is in it |
model-paths: ["models"] | where dbt looks for models. Alongside it, test-paths, macro-paths and analysis-paths point at this project's singular tests, its three hand-written macros, and the analyses folder |
clean-targets | what dbt clean is allowed to delete. Worth reading before you run it, because it deletes without asking |
models: | the layer block: materialization and schema per folder. This is the interesting part, and Part 2 is about it |
vars: | project-level variables. Here, the analysis windows the business questions use, so no model hard-codes a date |
Two things are worth noticing by their absence. There is no list of models, because dbt discovers them from the folder. And there is no build order, because dbt derives that from ref() - the subject of session b4. A config file that had to list either of those would be a config file somebody forgets to update.
Liveprofiles.yml, targets, and the reason secrets stay out of the repo4 min▶
The profile for this project is four lines long because DuckDB is a file:
bazaar:the profile name. It has to matchprofile:indbt_project.yml, and a mismatch here is the single most common first-day error in dbt.target: devwhich output is used when you do not say. Everything else inoutputs:is available withdbt build --target prod.outputs: dev:one named environment.type: duckdbpluspath: ../bazaar.duckdbis this project's entire connection; on Snowflake or BigQuery the same block holds an account, a role, a warehouse, a dataset and a key.threads: 4how many models dbt builds at once. The DAG decides what can run in parallel; threads decide how much of that permission you use.
In this course the profile sits inside the project so the repository runs with one command and no setup. In production it belongs in ~/.dbt/profiles.yml or in environment variables, for a reason worth stating plainly: the moment a credential is in git it is in every clone, every fork and every CI log forever, and rotating it becomes a project rather than a task.
dev points at a personal schema, ci at a throwaway one, prod at the real thing. The models never change. That is what makes "test it before shipping it" a habit rather than a heroic effort.
Self-studyThe four config mistakes that cost a whole afternoon3 min read▶
- Profile name mismatch.
profile: bazaarin the project,bazaar_duckdb:in the profiles file. dbt cannot guess, so it fails at parse time with a message about a missing profile. Read the two names side by side before you read anything else. - The wrong indentation under
models:. Configs cascade by folder, and a+materialized:one level too high applies to every model in the project. Symptom: your staging views are suddenly tables and the build got slower for no reason. - Forgetting that
+prefix.materialized: viewwithout the plus is read as a folder name, not a config. dbt tells you it cannot find those models. The plus means "this is a config, not a path". - Editing config in models when it belongs in the project. A
{{ config(materialized='table') }}block inside a model wins over the project file, which is correct and useful for one exception - and a mess when it is the normal way your team sets materializations. Exceptions belong in the model; policy belongs indbt_project.yml.
Everything in the layer block can also be set per model, per folder or on the command line, and the precedence runs from most specific to least. If a model behaves in a way the project file does not explain, check the model's own config block first.
Materialization is a decision per layer 8 min live
A materialization is dbt's answer to "what should this SELECT become in the warehouse". There are four you will use, and the useful move is to decide them per layer and write the decision once, rather than deciding per model and having twenty-three separate opinions nobody can summarise.
| Materialization | When it is the right answer | What it costs |
|---|---|---|
view | staging. Thin transformations that are read by other models rather than by people, and must never be stale. All 8 staging models here | the work happens on every read, so a view over a view over a view eventually hurts the person querying the top |
table | marts. Anything queried constantly by dashboards or people, or expensive enough that you want the cost paid once per build. All 12 marts here | storage, plus staleness between builds. A table is only as fresh as your last run, which is why scheduling matters and why that is learn-dataops |
ephemeral | intermediate. Plumbing that exists to keep a mart readable: joins, fan-out guards, a payment roll-up. The 3 intermediate models here | nothing is materialized, so you cannot query it while debugging and a deep chain makes one enormous compiled query. Read target/compiled/ when it confuses you |
incremental | only when a full rebuild genuinely hurts: hours of compute, or a bill you can feel. Not used in this project, and honestly so - the biggest table here is 1,434 rows | state to reason about. You now own a unique_key, an is_incremental() branch, late-arriving rows, and a --full-refresh escape hatch somebody has to remember exists |
LiveWhy per layer beats per model4 min▶
Materialization per model looks like flexibility and behaves like entropy. Four things go wrong, reliably:
- Nobody can state the policy. Ask a team with per-model configs what their staging layer is materialized as and you get "it depends". Ask a team with a layer block and you get "views" plus the reason.
- Drift is invisible. One staging model built as a table because somebody was debugging a slow dashboard in March is indistinguishable from a deliberate decision six months later.
- The layer stops meaning anything. Layers are only useful as a promise about cost and freshness. A staging layer where some models are tables and some are views makes no promise at all.
- Review gets harder. A change to the project file is one diff a reviewer reads in ten seconds. Twenty-three config blocks are twenty-three places to look.
The rule I would defend in any code review: policy in dbt_project.yml, exceptions in the model, and an exception needs a comment saying why. One mart that is genuinely too expensive to rebuild can absolutely be incremental. It just needs to be visibly an exception rather than the start of a trend.
OK created sql view model main_staging.stg_bazaar__users. If that says table where you expected view, your config cascade is not doing what you think.
Live+schema, and where objects actually land3 min▶
+schema: staging does not mean "put these in a schema called staging". It means "take the schema from my target and append _staging". In this project the target schema is main, so the objects land in main_staging and main_marts - which you saw in b1 and will query again in a minute.
That suffix behaviour is what makes multi-environment work painless. Your target uses your own schema, so your staging views are dbt_phoebe_staging and mine are dbt_sam_staging, from identical code. Production uses the production schema and gets analytics_staging. Nobody overwrites anybody, and no model contains the word "dev".
Two consequences worth knowing before they surprise you:
- The intermediate
+schemais never used. It sits in the config next toephemeral, and no schema is ever created, because nothing is ever materialized. Harmless, and a good question to ask in an interview. - Renaming a schema config renames objects. Anything outside dbt that points at the old name breaks on the next build. That is the blast-radius problem b4 makes visible and b9's contracts try to contain.
Self-studyvars: the dates no model is allowed to hard-code3 min read▶
Four variables live in this project's vars: block, and they exist because of a specific failure mode. The business questions this warehouse answers compare "the last fortnight" with "the fortnight before". Written the obvious way, '2026-06-16' appears in a mart, then in a second mart, then in an analyst's ad hoc query, then in a dashboard filter - and six weeks later three of the four have been updated.
recent_window_start: "2026-06-16"andprior_window_start: "2026-06-02"- the two fortnights, defined once. A model reads them as{{ var('recent_window_start') }}.incident_day: "2026-06-10"- the day the merchant drop began, so the incident analysis and the marts agree about which day is being discussed.small_sample_floor: 30- the row count below which a rate is not worth reporting. A threshold is a business decision, and business decisions belong in config rather than buried in aHAVINGclause.
Vars can also be overridden per invocation: dbt build --vars '{"recent_window_start": "2026-05-01"}'. That is how you re-run last month's numbers without editing a model, and it is a much better answer than a copy of the mart with a different date in it.
The honest limit: vars are project-wide and untyped, so they are excellent for a handful of windows and thresholds and a poor substitute for a real configuration table when there are fifty. If you find yourself with fifty, the answer is a seed or a source, not a longer vars block.
See the config in the database ★ 13 min · everyone builds
Config is only interesting because it changes what exists in the warehouse. These editors run real DuckDB in your browser with this project already built, so every claim in Part 2 is checkable rather than assertable. The first Run downloads the engine (about 8 MB, once), then everything is local.
Read the layer block back out of the database. Two schemas, two materializations, exactly as declared - and no third schema for intermediate.
Prove ephemeral means nothing lands. Not a table, not a view, not a schema. Three models with no footprint at all.
Then change the raw data underneath both layers and watch the view move while the table does not. That difference is the entire view-versus-table decision, in one query.
SELECT table_schema AS schema_name,
table_type,
count(*) AS objects,
CASE table_schema
WHEN 'main_staging' THEN 'staging: +materialized: view'
WHEN 'main_marts' THEN 'marts: +materialized: table'
END AS declared_in_dbt_project_yml
FROM information_schema.tables
WHERE table_schema IN ('main_staging', 'main_marts')
GROUP BY 1, 2
ORDER BY 1;
SELECT count(*) AS objects_in_an_intermediate_schema FROM information_schema.tables WHERE table_schema LIKE '%intermediate%' OR table_name LIKE 'int_%';
INSERT INTO raw.orders VALUES (900001, 1, 1, '2026-06-29 20:00:00', 'paid', 'SG', 'SGD');
INSERT INTO raw.order_items VALUES (900001, 1, 1, 'M01', 1, 100.00, 0.00);
SELECT 'main_staging.stg_bazaar__order_items (view)' AS relation,
(SELECT count(*) FROM main_staging.stg_bazaar__order_items) AS line_rows,
'a view is a stored query - the new line is already there' AS what_the_config_bought
UNION ALL
SELECT 'main_marts.fct_order_items (table)',
(SELECT count(*) FROM main_marts.fct_order_items),
'a table is a stored result - stale until the next dbt run';
That two-row result is the whole trade, and it is why the argument is never "which materialization is better". The view is always right and pays for it on every read. The table is fast and pays for it in staleness, which is a scheduling problem you have now created for somebody else. Teams that get this wrong usually get it wrong in one direction: they materialize staging as tables because a dashboard was slow, and six months later have a warehouse where nothing is fresh, the build takes an hour, and the actual fix was a table at the mart layer with an index on the column the dashboard filtered.
Your turn: vars, and when incremental earns its keep ★ 8 min · build your own
Two questions you can answer from the built project, and one you can only answer about your own.
LiveQ1 · What the vars actually resolve to4 min▶
The browser cannot render Jinja, so the two dates below are written as literals - exactly what {{ var('recent_window_start') }} compiles to. The point is that they partition the tail of a 90-day dataset cleanly, and that they are the same two dates every model uses.
SELECT '2026-06-16' AS recent_window_start,
'2026-06-02' AS prior_window_start,
count(*) FILTER (WHERE activity_date >= '2026-06-16') AS recent_rows,
count(*) FILTER (WHERE activity_date >= '2026-06-02'
AND activity_date < '2026-06-16') AS prior_rows,
min(activity_date) AS first_day,
max(activity_date) AS last_day
FROM main_marts.mart_merchant_daily;
Change one of those literals and the row counts move together, which is the property you want. In a project where the date is written in four models, changing one of them moves one number and produces an argument.
LiveQ2 · Would incremental help this project?4 min▶
SELECT 'main_marts.fct_carts' AS relation, count(*) AS row_count FROM main_marts.fct_carts UNION ALL SELECT 'main_marts.fct_order_items', count(*) FROM main_marts.fct_order_items UNION ALL SELECT 'main_marts.fct_transactions', count(*) FROM main_marts.fct_transactions UNION ALL SELECT 'main_marts.mart_merchant_daily', count(*) FROM main_marts.mart_merchant_daily UNION ALL SELECT 'main_marts.mart_funnel_daily', count(*) FROM main_marts.mart_funnel_daily ORDER BY row_count DESC;
This is the honest test for incremental, and it is a measurement rather than a preference. Rebuild cost you can feel, on a table where new rows genuinely arrive by append, is a yes. Anything else is complexity you will be maintaining at 2am when the unique_key turns out not to be unique.
Self-studyQ3 · Write the config for your own project3 min▶
No SQL. Take the transformation estate you actually have and answer five questions on paper:
- What are your layers? If you cannot name them, that is the finding. Most estates have staging and marts under different names plus a large middle nobody has classified.
- What materialization would each layer get, and what does that cost? Views cost read time, tables cost storage and freshness, ephemeral costs debuggability.
- Where would objects land, per environment? If dev and prod would need different code, you have found the thing profiles and targets exist to remove.
- Which dates and thresholds are currently hard-coded in more than one place? Those are your vars, and the count is usually higher than expected.
- What is genuinely expensive enough to be incremental? Be strict. The answer is often nothing, and "nothing" is a good answer.
One more honest note on scope, since config is where people expect it to appear: nothing in either file schedules anything. dbt has no opinion about when it runs, and a table materialization is exactly as fresh as your orchestrator makes it. That is learn-dataops, and getting the data into raw in the first place is learn-data-engineering.
Try it yourself - this week ◐ 20-30 min total
- Open this project's
dbt_project.ymland changestagingto+materialized: table. Rundbt buildand read the output: same numbers, different objects, slower build. Then change it back and notice you did not touch a single model. - Add a second target to
profiles.ymlcalledprodpointing at a different DuckDB file, then rundbt build --target prod. You now have two warehouses from one repository, which is the whole idea. - Override a var at the command line:
dbt build --select mart_merchant_daily --vars '{"recent_window_start": "2026-05-01"}'. Confirm the numbers move without a code change. - Grep your own project for hard-coded dates and thresholds. Every hit is a candidate var, and every duplicate hit is a future disagreement between two dashboards.
- Write down, in one sentence each, your project's materialization policy per layer. If you cannot, that is the most valuable thing this session has told you, and b3 is where the discipline starts.
Sources covered
This page covers:
profiles.yml, targets, threads, varsPart 1 · both files of this project, line by lineunique_key, is_incremental(), late-arriving rowsNamed with the trade-off stated · 1,434 rows is not a reason to use itThree questions before you go 🎯 ◐ 90 seconds
1 · Why is the connection resolved from profiles.yml rather than declared in dbt_project.yml?
The project file says what gets built; the profile says where it lands and with what credentials. That seam is why a code review is about the model, why a new joiner gets their own sandbox from the same repo, and why a secret never ends up in every clone and CI log.
2 · Staging is view and marts are table in this project. What is the actual difference you can observe?
A view is a stored query, so it is always fresh and pays the cost on every read. A table is a stored result, so it is fast and exactly as fresh as your last build. Demo 1 proves it: 1,435 rows in the view against 1,434 in the table.
3 · When is incremental the right materialization?
Incremental trades simplicity for compute. This project's biggest table is 2,229 rows and the whole build takes 1.22 seconds, so incremental here would be pure cost. Measure the rebuild before you take on the state.