Skip to main content
dbt beginner Lesson 5 of 10

Materializations: View, Table, Ephemeral

The same SELECT stored four different ways — what dbt runs for each, how config precedence resolves, and how to choose without guessing.

Every dbt model is a SELECT. The materialization decides what dbt wraps around it — and that decision is where all the cost and speed trade-offs live.

The same model, four ways

-- models/marts/daily_revenue.sql
select
    ordered_at,
    count(*)    as orders,
    sum(amount) as revenue
from {{ ref('stg_orders') }}
group by 1

view

{{ config(materialized='view') }}
12:03:11  1 of 1 START sql view model main.daily_revenue ................. [RUN]
12:03:11  1 of 1 OK created sql view model main.daily_revenue ............ [OK in 0.04s]
create view "bookshop"."main"."daily_revenue__dbt_tmp" as (
    select ordered_at, count(*) as orders, sum(amount) as revenue
    from "bookshop"."main"."stg_orders"
    group by 1
);

Nothing is computed at build time. Every query against daily_revenue re-runs the aggregation.

table

{{ config(materialized='table') }}
12:04:29  1 of 1 START sql table model main.daily_revenue ................ [RUN]
12:04:29  1 of 1 OK created sql table model main.daily_revenue ........... [OK in 0.07s]
create table "bookshop"."main"."daily_revenue__dbt_tmp" as (
    select ordered_at, count(*) as orders, sum(amount) as revenue
    from "bookshop"."main"."stg_orders"
    group by 1
);

Computed once per run, then cheap to read — and stale until the next run. dbt builds into a __dbt_tmp relation and swaps it in at the end, so readers never see a half-built table.

ephemeral

{{ config(materialized='ephemeral') }}
12:05:47  Found 5 models, 2 seeds, 431 macros
12:05:47
12:05:47  1 of 1 START sql table model main.country_revenue .............. [RUN]
12:05:47  1 of 1 OK created sql table model main.country_revenue ......... [OK in 0.06s]
12:05:47
12:05:47  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1

daily_revenue does not appear in the run at all — it was inlined as a CTE into the model that referenced it. Nothing exists in the warehouse to query, and nothing exists to test:

12:06:30  Encountered an error:
Compilation Error in test unique_daily_revenue_ordered_at
  Cannot test an ephemeral model 'daily_revenue' — it has no relation in the database

incremental

{{ config(materialized='incremental', unique_key='ordered_at') }}

select ordered_at, count(*) as orders, sum(amount) as revenue
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where ordered_at > (select max(ordered_at) from {{ this }})
{% endif %}
group by 1
12:08:15  1 of 1 START sql incremental model main.daily_revenue .......... [RUN]
12:08:15  1 of 1 OK created sql incremental model main.daily_revenue ..... [OK in 0.09s]

Full table on the first run, then only new rows merged in on later ones. It is the answer to “this table is too big to rebuild nightly”, and it has enough sharp edges to deserve its own lesson — that is lesson 6.

Choosing

Build costQuery costAlways currentQueryable
viewnonefullyesyes
tablefullnonenoyes
ephemeralnonefull, duplicated per consumeryesno
incrementalnew rows onlynonenoyes

The default that works for most projects:

# dbt_project.yml
models:
  bookshop:
    staging:
      +materialized: view
    intermediate:
      +materialized: ephemeral
    marts:
      +materialized: table

Staging models are thin renames — a view costs nothing and never goes stale. Marts are joined and aggregated and read repeatedly by dashboards, so pay the cost once at build. Move a mart to incremental only when its build time actually hurts.

The trap is stacked views. A view on a view on a view compiles to one enormous query, and the warehouse re-runs every layer on each read:

12:14:02  Finished running 12 view models in 0 hours 0 minutes and 0.41 seconds (0.41s).

Fast to build, and the dashboard on top of it takes 40 seconds. Build time is not the metric to optimise — a slow dbt run with a fast warehouse is usually the right trade.

Where config comes from

Three places, least to most specific:

# 1. dbt_project.yml — a folder default
models:
  bookshop:
    marts:
      +materialized: table
      +tags: ['nightly']
# 2. models/marts/_marts.yml — one model
models:
  - name: daily_revenue
    config:
      materialized: view
-- 3. the model file itself — wins over both
{{ config(materialized='table', tags=['hourly']) }}
dbt run --select daily_revenue
12:18:44  1 of 1 START sql table model main.daily_revenue ................ [RUN]
12:18:44  1 of 1 OK created sql table model main.daily_revenue ........... [OK in 0.07s]

“table” — the in-file config won. Most projects set folder defaults in dbt_project.yml and override in the file only where a model genuinely differs. Scattering {{ config() }} calls everywhere makes the project’s storage strategy impossible to see at a glance.

Note that +tags merged rather than replaced: this model carries both nightly and hourly. Materializations override, list configs like tags accumulate.

Where the model lands

{{ config(materialized='table', schema='reporting', alias='revenue_by_day') }}
12:22:05  1 of 1 START sql table model main_reporting.revenue_by_day ..... [RUN]
12:22:05  1 of 1 OK created sql table model main_reporting.revenue_by_day  [OK in 0.07s]

Note main_reporting, not reporting. By default dbt appends a custom schema to the target schema, which surprises everyone once. It is deliberate — it keeps two developers writing to dev_alice_reporting and dev_bob_reporting rather than colliding — and it is overridable with a generate_schema_name macro (lesson 7).

Rebuilding from scratch

dbt run --full-refresh
12:25:30  1 of 1 START sql incremental model main.daily_revenue .......... [RUN]
12:25:30  1 of 1 OK created sql incremental model main.daily_revenue ..... [OK in 0.09s]

--full-refresh drops and recreates incremental models rather than appending. It does nothing to views and tables, which are rebuilt every run anyway. When an incremental model’s numbers look wrong, this is the first thing to try — and lesson 6 explains why it works.

Practice

1. Switch customer_orders from table to view and time both.
# table
12:31:02  1 of 1 OK created sql table model main.customer_orders ......... [OK in 0.08s]
# view
12:31:20  1 of 1 OK created sql view model main.customer_orders .......... [OK in 0.03s]

The view builds faster because it computes nothing. Then query both: the table answers instantly and the view re-runs the join and aggregation every time. On five rows this is invisible; on fifty million it is the entire decision.

2. Make a model ephemeral and try to test it.
Compilation Error in test not_null_int_orders_enriched_order_id
  Cannot test an ephemeral model 'int_orders_enriched' — it has no relation in the database

This is the real cost of ephemeral, and it is easy to miss when you convert a model that already had tests. If a model is worth testing, it is worth materialising.

3. Set a folder default and override it in one file.
models:
  bookshop:
    marts:
      +materialized: view
-- models/marts/daily_revenue.sql
{{ config(materialized='table') }}
12:36:41  1 of 3 START sql view model main.customer_orders ............... [RUN]
12:36:41  2 of 3 START sql table model main.daily_revenue ................ [RUN]
12:36:41  3 of 3 START sql view model main.country_revenue .............. [RUN]

12:36:41  Done. PASS=3 WARN=0 ERROR=0 SKIP=0 TOTAL=3

One table among views, from the file-level override. The run output is the quickest way to audit what your config actually resolved to.

4. Give a model a custom schema and find where it was created.
duckdb bookshop.duckdb -c "select table_schema, table_name from information_schema.tables order by 1,2"
┌────────────────┬──────────────────┐
│  table_schema  │    table_name    │
├────────────────┼──────────────────┤
│ main           │ customer_orders  │
│ main           │ raw_customers    │
│ main           │ raw_orders       │
│ main           │ stg_orders       │
│ main_reporting │ revenue_by_day   │
└────────────────┴──────────────────┘

main_reporting — target schema plus custom schema. If you expected a bare reporting, that concatenation is the behaviour to override, not a bug.

Next: incremental models — building only what changed, and the ways that goes wrong.

Frequently Asked Questions

What is a materialization in dbt?
The strategy dbt uses to persist a model's SELECT — as a view, a table, an inlined CTE, or an incrementally updated table. It is a config value, not part of your SQL, so switching between them never means rewriting the query.
Should a model be a view or a table?
Views are free to build and always current, but pay the query cost on every read. Tables cost time and storage at build and are fast to query. Use views for light staging models and tables for anything joined, aggregated, or read by a dashboard.
When should I use an ephemeral model?
For small intermediate logic that no one queries directly and that only one or two models consume. It keeps the warehouse tidy, at the cost of being undebuggable — there is no relation to select from — and of duplicating the SQL into every consumer.
Where do I set a model's materialization?
In `dbt_project.yml` for a whole folder, in a YAML `config:` block for one model, or in a `{{ config() }}` call at the top of the SQL file. The most specific one wins, so a file-level config overrides the project default.