Module 3: Ref Marts And Materializations

Choosing materializations for Kiosko

Description

Lesson 3 gave you the mechanism — exactly what tells view apart from table — and the general argument — cost of recomputing, freshness versus stability, disk space. This lesson applies that argument to each concrete model in Kiosko's project, and updates dbt_project.yml so the decision gets declared once, at the project level, instead of repeated model by model.

Until now, every mart you built (lessons 4 and 5) inherited table implicitly, with dbt_project.yml not declaring it yet — you were relying on the project's historical default (view, since module 1) without having changed it. This lesson closes that gap: it changes the whole project's default to table, and explicitly pins staging to view, delivering exactly what module 2's lesson 6 already anticipated.

Connection to the module. Lessons 4 and 5 built the three marts, but left a configuration question unresolved: where, exactly, did the table materialization those models used come from? This lesson answers that question with the correct config file, and with an explicit criterion for every model in the project.

The promise this module delivers

Go back to module 2's lesson 6 for a moment. There, while explaining why the staging: +materialized: view block survives even if the project's default changes, the lesson said, verbatim: "if module 3 changed the whole project's default to table (something that, in fact, is going to happen once the marts start needing materialized tables)". This is, precisely, that lesson delivering on its own promise.

Update dbt_project.yml:

# dbt_project.yml
name: "kiosko_analytics"
version: "1.0.0"
config-version: 2

profile: "kiosko_analytics"

model-paths: ["models"]
seed-paths: ["seeds"]
test-paths: ["tests"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]

target-path: "target"
clean-targets:
  - "target"
  - "dbt_packages"

models:
  kiosko_analytics:
    +materialized: table
    staging:
      +materialized: view

A single character changed from module 2: view became table on the whole-project line (kiosko_analytics:), while the nested line (staging:) still says view, with no change at all. Confirm this doesn't break anything by running the complete project again:

dbt run

What to expect.

Running with dbt=1.12.2
Registered adapter: duckdb=1.11.0
Found 7 models, 8 data tests, 4 sources, 500 macros

Concurrency: 4 threads (target='dev')

1 of 7 START sql table model main.dim_date ..................................... [RUN]
2 of 7 START sql view model main.stg_events .................................... [RUN]
3 of 7 START sql view model main.stg_orders .................................... [RUN]
4 of 7 START sql view model main.stg_products .................................. [RUN]
2 of 7 OK created sql view model main.stg_events ............................... [OK in 0.08s]
4 of 7 OK created sql view model main.stg_products ............................. [OK in 0.08s]
1 of 7 OK created sql table model main.dim_date ................................ [OK in 0.09s]
3 of 7 OK created sql view model main.stg_orders ............................... [OK in 0.09s]
5 of 7 START sql view model main.stg_stores .................................... [RUN]
5 of 7 OK created sql view model main.stg_stores ............................... [OK in 0.01s]
6 of 7 START sql table model main.dim_store .................................... [RUN]
6 of 7 OK created sql table model main.dim_store ............................... [OK in 0.01s]
7 of 7 START sql table model main.fact_orders .................................. [RUN]
7 of 7 OK created sql table model main.fact_orders ............................. [OK in 0.02s]

Finished running 3 table models, 4 view models in 0 hours 0 minutes and 0.21 seconds (0.21s).

Completed successfully

Done. PASS=7 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=7

Exactly the same result you already saw in lesson 5 — because, technically, this change doesn't alter any behavior yet: the three marts were already running as table, inherited from the historical default, and this change only makes that decision explicit, instead of leaving it as a configuration accident no one declared on purpose. The real difference is in what happens the day you add a new model without thinking about it: before this lesson, a new model in models/marts/ would inherit view (the old default); after this lesson, it inherits table — the materialization that, as you're going to confirm in this lesson, is the right one for almost any mart in this project.

Confirming each model's materialization

Instead of trusting memory, ask dbt to tell you, for each model, what materialization it actually resolved:

dbt list --resource-type model --select stg_stores dim_store dim_date fact_orders --output json --output-keys name resource_type config.materialized

What to expect.

{"name": "dim_date", "resource_type": "model", "config.materialized": "table"}
{"name": "dim_store", "resource_type": "model", "config.materialized": "table"}
{"name": "fact_orders", "resource_type": "model", "config.materialized": "table"}
{"name": "stg_stores", "resource_type": "model", "config.materialized": "view"}

Four models, two different materializations, exactly where each one has to be: the three marts on table, stg_stores (representing all four staging models) on view. This command — the same dbt list --output json --output-keys mechanism you already used in module 2's lesson 6 — is the correct way to verify a materialization, instead of assuming it from where the file lives in the folder.

The complete decision matrix

With the project's seven models already built, this is the table that summarizes lesson 3's criterion, applied model by model:

ModelMaterializationWhy
stg_orders, stg_events, stg_stores, stg_productsviewNo JOIN or GROUP BY (module 2's rule) — recomputing is nearly free, and it always reflects the current raw data.
dim_storetableConsumed by fact_orders (and, in the capstone, by more models) — paying its cost once per run beats recomputing it on every downstream query.
dim_datetableA recursive CTE's result doesn't change between runs (the range is fixed) — there's no reason to recompute it on every use.
fact_orderstableHas two real JOINs against dim_store and dim_date — the most expensive model in the whole project to recompute, and the one most likely to be queried by several people or dashboards at once.

Notice the pattern: every mart in this module is table, not because "marts are always tables" as a blind rule, but because each of the three, for different reasons, meets lesson 3's criterion — non-trivial work to recompute, and/or repeated consumption. If Kiosko someday had a trivial mart — an almost literal copy of a single table, with no JOIN at all — the question would still be valid, and the answer could, legitimately, be view. The folder (marts/ versus staging/) is an organizational convention, not an automatic materialization guarantee — it's dbt_project.yml's configuration, not the file's location, that decides.

Common mistakes

Thinking the folder determines the materialization, without checking dbt_project.yml. What happens: someone creates a new model inside models/marts/ and assumes, without checking, that it's going to be table "because it's in marts." Why it happens: in this specific project, today, that assumption happens to be correct — but it's correct because of dbt_project.yml's configuration, not because of any magic dbt rule about folder names. How to spot it: if someday someone adds a marts: +materialized: view block inside dbt_project.yml (for example, for a special case), any new model in that folder would inherit view, with the folder's name giving no hint of it. How to fix it: the only reliable way to confirm a model's materialization is to ask dbt directly, with dbt list --output-keys config.materialized, as you did in this lesson — never assume it from the file's location.

Changing the project's default to table without pinning staging to view first. What happens: someone makes this lesson's change in the wrong order — changes the project's default before confirming the nested staging: +materialized: view block already exists — and the four staging views end up materializing as table with no one having decided that on purpose. Why it happens: it's easy to forget that a change at the most general level of configuration propagates to everything that doesn't have a more specific configuration overriding it. How to spot it: after any change to dbt_project.yml, run this lesson's same dbt list --output-keys config.materialized over every model in the project — staging included, not just the ones you just touched. How to fix it: the correct order, which you already followed since module 2, is to declare the specific configuration first (staging: +materialized: view) and then change the general default, trusting that the specific configuration keeps winning — never the other way around.

Materializing fact_orders as view "to save space," without measuring the JOIN's real cost. What happens: someone, worried about the disk space a table takes up, changes fact_orders back to view. Why it happens: "saving space" sounds like a reasonable precaution, without knowing the project's real data volume. How to spot it: with Kiosko's toy data (40 rows), the space difference is completely irrelevant — a few kilobytes; the real argument was never space, it was the cost of recomputing the JOIN on every query. How to fix it: for fact_orders specifically, disk space was never the deciding criterion (lesson 3's table mentions it as the least important of the three factors) — what matters is that two real JOINs, queried repeatedly, get paid once per run and not once per query.

Exercises

Exercise 1 — Predict a hypothetical model's materialization. Without running anything, answer: if you added a new model, models/marts/dim_product.sql (a simple translation of stg_products, with no JOIN at all), what materialization would it inherit under dbt_project.yml's current configuration?

See solution

table — any model inside models/marts/ (that isn't also part of the staging/ folder) inherits the whole-project-level default, which this lesson changed to table. It doesn't matter that dim_product in this hypothetical example has no expensive JOIN at all — the current configuration doesn't distinguish between "simple" and "complex" marts, it treats them all the same, unless someone adds a more specific configuration (for example, a +materialized: view placed directly inside the .sql file itself, with {{ config(materialized='view') }} at the top of the model, syntax this guide doesn't use but dbt does support).

Exercise 2 — Verify the project's seven resources all at once. Extend the worked example's dbt list command so it shows the materialization of all seven models in the project (the four staging ones plus the three marts) in a single call.

See solution
dbt list --resource-type model --select staging marts --output json --output-keys name resource_type config.materialized

What to expect (order may vary).

{"name": "stg_events", "resource_type": "model", "config.materialized": "view"}
{"name": "stg_orders", "resource_type": "model", "config.materialized": "view"}
{"name": "stg_products", "resource_type": "model", "config.materialized": "view"}
{"name": "stg_stores", "resource_type": "model", "config.materialized": "view"}
{"name": "dim_date", "resource_type": "model", "config.materialized": "table"}
{"name": "dim_store", "resource_type": "model", "config.materialized": "table"}
{"name": "fact_orders", "resource_type": "model", "config.materialized": "table"}

--select staging marts selects both folders at once (two --select arguments combine as "either one"), confirming at a glance the complete split: four view, three table, with no exception.

Exercise 3 — Argue dim_date's decision in your own words. Of the three marts, dim_date is the only one that depends on no external data (lesson 4) — only on a recursive CTE with a fixed range. In 2-3 sentences, explain why, even so, it makes sense for it to be table and not view.

See solution

Even though dim_date reads no external data, its SELECT is still real work to compute — a recursive CTE that generates 31 rows and applies several date functions to each one — and that result never changes between one run and the next, because the range is fixed with date literals, not with CURRENT_DATE. Materializing it as view would mean repeating that calculation — small, but not free — every time another model (like fact_orders) queries it, for no reason: the result is going to be identical every time, so paying the cost once, with table, is strictly better than paying it on every JOIN that uses it.

Summary and next step

In this lesson you closed the promise module 2 left open: dbt_project.yml now declares table as the whole project's default, with staging explicitly pinned to view — the configuration this module's three marts had been using implicitly, now made explicit and verifiable. You built the complete decision matrix for the project's seven models, and confirmed with dbt list --output-keys config.materialized that each one really has the materialization it's supposed to have.

Before moving on you should be able to: explain why this module's three marts are table for different reasons (not one single generic rule); and use dbt list --output-keys config.materialized to verify any model's real materialization, without assuming it from its location.

Lesson 7 teaches you to read the complete DAG ref() built throughout this module, with dbt ls — without running a single model, just inspecting the dependency structure that already exists.

Resources

  • dbt Developer Hub — "Model configurations," the official reference for how dbt resolves nested configuration by folder — the technical basis for this lesson's change, already cited in module 2's lesson 6. docs.getdbt.com/reference/model-configs. In English.
  • dbt Developer Hub — "About materializations," again lesson 3's central reference, now applied with judgment to the project's seven models. docs.getdbt.com/docs/build/materializations. In English.
  • dbt Developer Hub — "The config Jinja function," the reference for {{ config(materialized='view') }}, the alternative syntax (mentioned in Exercise 1) for pinning an individual model's materialization inside the .sql file itself, instead of in dbt_project.yml. docs.getdbt.com/reference/dbt-jinja-functions/config. In English.