learn-analytics-engineering-with-phoebe / Builder session 2 of 10
Learn Analytics Engineering with Phoebe · Builder track · Session 2 of 10

The project config

Two small YAML files decide almost everything about how this project behaves. dbt_project.yml says what gets built and how; profiles.yml says where it lands and with what credentials. Neither is boilerplate you copy once and forget: every line is a decision that changes an object in your warehouse. Today we walk both files line by line, argue about materializations as a per-layer choice rather than a per-model habit, and prove in a live warehouse what "view" and "table" actually mean when the raw data changes underneath you.

🟢 Builder track Analytics engineers · analysts · DE Runs in your browser · real DuckDB 45 min
0-3 · Welcome 3-17 · Two files, two jobs 17-42 · Materializations, schemas and vars 42-45 · Q&A
Part 0

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.

Live - presented in session Self-study - read after class ▶ Live SQL - real DuckDB, editable Sources covered
★ What you walk out with today Both config files read line by line as decisions rather than syntax, a defensible answer to "view or table or ephemeral or incremental" for each layer, and proof in a live warehouse of what a view gives you that a table does not.
Part 1 · covers dbt documentation on project configuration and profiles

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.

dbt_project.yml · what gets built name: bazaar - the project's own identity profile: bazaar - a pointer, not a connection model-paths, macro-paths - where dbt looks models: - materialization per LAYER vars: - the analysis windows, defined once committed to git, reviewed in pull requests profiles.yml · where it lands target: dev - which output is the default type: duckdb - which warehouse, which driver path / host / schema - how to connect threads: 4 - how much parallelism to use credentials - never in version control lives in ~/.dbt/, one per machine per person Why the split is not bureaucracy the same project, unchanged, runs against dev or prod by switching one target nothing about the environment is in the code, and no secret is in the repository a new joiner clones the repo, writes their own profile, and gets their own sandbox so a code review is about the model, never about somebody's connection string
🔍 Click to zoom - what gets built, versus where it lands
Livedbt_project.yml, line by line4 min▶

This is the whole file for the Bazaar project. Nothing has been trimmed for the slide.

LineThe decision it makes
name: bazaarthe 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: bazaara 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-targetswhat 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 match profile: in dbt_project.yml, and a mismatch here is the single most common first-day error in dbt.
  • target: dev which output is used when you do not say. Everything else in outputs: is available with dbt build --target prod.
  • outputs: dev: one named environment. type: duckdb plus path: ../bazaar.duckdb is 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: 4 how 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.

The pattern to copy One profile, several targets, credentials from environment variables. 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: bazaar in 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: view without 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 in dbt_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.

Part 2 · covers dbt documentation on materializations, plus the community layering convention

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.

Layer declared once as what lands in the database why that is the right call staging +materialized: view 8 views in main_staging cheap, always fresh, no storage intermediate +materialized: ephemeral nothing lands at all inlined as a CTE, cannot be misused marts +materialized: table 12 tables in main_marts built once, then queried all day not used here +materialized: incremental a table, appended per run state, plus a full-refresh path +schema: staging becomes main_staging - the target's schema plus a suffix so dev and prod differ by one prefix, and nobody queries the wrong layer by accident vars: one definition of "the last fortnight" recent_window_start 2026-06-16 · prior_window_start 2026-06-02 · incident_day 2026-06-10 no model hard-codes a date, so no two models can disagree about the window
🔍 Click to zoom - four layers, four decisions, one file
MaterializationWhen it is the right answerWhat it costs
viewstaging. Thin transformations that are read by other models rather than by people, and must never be stale. All 8 staging models herethe work happens on every read, so a view over a view over a view eventually hurts the person querying the top
tablemarts. Anything queried constantly by dashboards or people, or expensive enough that you want the cost paid once per build. All 12 marts herestorage, 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
ephemeralintermediate. Plumbing that exists to keep a mart readable: joins, fan-out guards, a payment roll-up. The 3 intermediate models herenothing 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
incrementalonly 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 rowsstate 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.

The cheapest sanity check there is The build output prints the materialization it actually used: 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 +schema is never used. It sits in the config next to ephemeral, 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" and prior_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 a HAVING clause.

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.

Demo 1 of 2

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';
Real world

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.

Demo 2 of 2

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.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Sources covered

This page covers:

✓dbt documentation - project configuration, profiles.yml, targets, threads, varsPart 1 · both files of this project, line by line
✓dbt documentation - models and materializations (view, table, ephemeral, incremental)Part 2 + Demo 1 · declared per layer, verified in the database
✓The community layering convention - materialization as a per-layer promisePart 2 · policy in the project file, exceptions in the model
◐Incremental models at scale - unique_key, is_incremental(), late-arriving rowsNamed with the trade-off stated · 1,434 rows is not a reason to use it
◐Kimball & Ross - why marts are the layer worth materializingTouched only · the model itself is learn-data-modeling
◐Scheduling and extractionOut of scope by design · scheduling is learn-dataops, extraction is learn-data-engineering
Check yourself

Three 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.

Builder session 2 cheat sheet · pin this

dbt_project.ymlWhat gets built and how. Committed to git, reviewed in pull requests.
profiles.ymlWhere it lands and with what credentials. Lives in ~/.dbt/, never in the repo.
profile:A pointer by name, not a connection. Mismatched names are the classic day-one error.
viewStaging. Cheap, always fresh, no storage - and pays on every read.
tableMarts. Built once, queried all day - and only as fresh as your last run.
ephemeralIntermediate. Inlined as a CTE, nothing lands, nothing to misuse or keep fresh.
incrementalOnly when a full rebuild hurts. Costs state, a unique key and a full-refresh path.
+schema and varsmain_staging and main_marts by suffix; windows defined once. Next: b3, sources.