skip to content

Materializations

The same SQL can become a view, a table, an incremental build, or be inlined as an ephemeral CTE. Interviewers ask when incremental is worth its complexity, and how unique_key and the chosen strategy keep it from producing duplicates.

on this pageshow

questions

6

In dbt, what is the difference between the view and table materializations?

level: juniorimportance: must knowfreq 85%

answer

  1. one stores SQL, the other stores rows
  2. think about who pays the compute
  3. build time versus read time
  4. freshness is free for one of them
  5. create or replace view vs create table as

basics

~20 s

In dbt, a view materialization stores only the SQL, so every read recomputes the query; a table materialization runs the query once per dbt run and stores the rows, making reads fast but data only as fresh as the last build.

solid answer

~40 s

Both are the same `SELECT` — the materialization decides what dbt builds from it. With `materialized='view'` dbt issues `create or replace view`, which is near-instant to build and always reflects current source data, but every downstream query re-executes the whole transformation. With `materialized='table'` dbt issues `create or replace table ... as (select ...)`, paying the compute once per `dbt run` and storing the result, so consumers scan physical rows. I default to views for thin staging models and cheap logic, and switch to tables when the SQL is expensive, when many models or dashboards read it, or when it sits under a stack of other views. A table costs build time, storage, and staleness between runs; a view costs that compute again on every single read.

code

sql · 10 lines
sql
-- models/staging/stg_orders.sql
{{ config(materialized='view') }}

select
    id as order_id,
    customer_id,
    cast(ordered_at as timestamp) as ordered_at,
    amount_cents / 100.0 as amount
from {{ source('shop', 'orders') }}
where not is_test

go deeper

for a junior

Be able to say what dbt actually issues for each: a create-or-replace view versus a create-table-as-select. Know that both are set with a config block and that the model SQL is identical either way.

for a middle

Explain who pays the compute and when. Talk about build cost versus read cost, the staleness window a table introduces, and why deep chains of views over large scans get slow.

for a senior

Show judgment about which layer gets which materialization across a project, and be ready to justify promoting an expensive intermediate model to a table when run time or dashboard latency data says so.

for a principal

Own the convention: folder-level defaults in the project file, the criteria for promoting a model to a table, and how materialization choices trade warehouse spend against freshness and run-window length across many teams.

## What a materialization is in dbt A dbt model is a file containing one `SELECT` statement. dbt does not run that `SELECT` as-is — it wraps it in DDL determined by the model's **materialization** config. The materialization is the answer to "what object should exist in the warehouse when this model is built?" The built-in options are `view`, `table`, `incremental` and `ephemeral`. The same SQL can be flipped between them by changing one config line, with no edit to the query itself. You set it either in the model file: ```sql {{ config(materialized='table') }} select ... ``` or as a default for a folder in `dbt_project.yml`, where the model-file config wins over the project-file default. ## The view materialization With `materialized='view'`, dbt runs roughly `create or replace view analytics.stg_orders as (select ...)`. Nothing is computed at build time beyond parsing and validating the SQL, so `dbt run` over a hundred views finishes in seconds and costs almost no warehouse compute. The trade is deferred: a view is a stored query, so the transformation executes **every time someone selects from it**. If a dashboard hits the view a thousand times a day, you pay for that logic a thousand times. Views are also always current — they read whatever is in the underlying tables at query time, so there is no staleness window. A subtle cost is stacking. If `mart_revenue` is a view over `int_orders_joined`, which is a view over `stg_orders`, which is a view over the raw table, a single dashboard query expands into one large nested statement. Most warehouses optimize this reasonably well, but plans get harder to read, and deep view chains over big scans are a common cause of "the dashboard got slow and nobody changed anything." ## The table materialization With `materialized='table'`, dbt runs the equivalent of `create or replace table analytics.dim_customers as (select ...)`, fully rebuilding the object on every run. Reads afterwards are plain scans of stored, physically laid-out data — the warehouse can prune, cluster, cache and compress it, which a view cannot do because a view holds no data. The costs are three: build compute (the query runs in full every `dbt run`), storage, and **staleness** — the table reflects the source as of the last build, so a table refreshed hourly is up to an hour behind while its view sibling is never behind. There is also a brief availability consideration: on most warehouses `create or replace` is atomic enough that readers see either the old or the new version, but a long rebuild means the *content* is old for the duration of the run. ## How to choose Useful defaults that hold across most projects: - **Staging models** (light renaming, casting, filtering, one source each) → `view`. They are cheap to recompute and you want them to reflect the source. - **Intermediate models** → usually `view`, promoted to `table` when they are expensive or reused by several downstream models. - **Marts and BI-facing models** → `table`, because many people query them repeatedly and read latency is what users feel. Promote a view to a table when any of these is true: the SQL is expensive (large joins, window functions, `distinct` over big volumes); it is read far more often than it is built; it is the base of further views; or a BI tool queries it interactively and latency matters. Demote a table to a view when the build is dominating your run time, the model is rarely queried, or the freshness requirement is tighter than your run schedule. ## Where the other materializations fit When a table's full rebuild becomes too slow or expensive — typically a large append-mostly fact table — the next step is `incremental`, which builds the table once and then only processes new or changed rows on later runs. When a model is just reusable interim logic that nothing needs to query directly, `ephemeral` inlines it as a CTE and creates no object at all. Views and tables remain the two you reach for first: incremental adds real correctness complexity, and ephemeral hides logic from anyone outside dbt. ## Things that trip people up Switching a model from view to table (or back) is safe — dbt drops the existing object and creates the new type — but the *name* stays the same, so downstream `ref()`s keep working untouched. A model's materialization is not a property of its SQL; it is an operational decision you can revisit when run times or query patterns change. And because both are fully rebuilt each run, neither view nor table needs any thought about duplicates or late-arriving data — that concern belongs entirely to incremental models.

  • If a view is always fresh and costs nothing to build, why not materialize everything as a view?
    Because you pay the transformation on every read instead of once per run. A view read by dashboards all day re-executes joins and aggregations each time, and views stacked on views turn one dashboard query into a deep nested plan. Tables let the warehouse store, prune and cache physical data, which is what makes interactive queries fast.
  • What happens when you change a dbt model from materialized='view' to materialized='table'?
    On the next run dbt drops the existing view and creates a table under the same name and schema, so every downstream `ref()` keeps resolving without edits. The first run pays the full build cost. Nothing about the model's SQL changes — the materialization is an operational choice, not a property of the query.
  • Where do you set a materialization so that a whole folder of dbt models shares it?
    In `dbt_project.yml` under the `models:` key, nested by project name and directory — for example every model in `models/staging` defaults to `+materialized: view`. A `{{ config(materialized=...) }}` block inside an individual model file overrides that folder default for just that model.

A view is a recipe card: cheap to write down, but somebody has to cook the dish every time it's ordered. A table is the cooked dish in the fridge: instant to serve, but you cooked it once and it only reflects the ingredients you had then.

saying these in an interview costs you the question

  • Claiming a dbt view stores data or is faster to query
  • Saying a table materialization keeps itself up to date automatically
  • Thinking the materialization changes the model's SQL, not just the DDL
  • Defaulting every model to table to make everything fast
  • Assuming you must rewrite downstream refs when a materialization changes

context

open as a page

In a dbt incremental model, when does the is_incremental() macro return true?

level: middleimportance: must knowfreq 76%

basics

~20 s

In dbt, is_incremental() returns true only when the model is materialized as incremental, its target relation already exists in the warehouse, and the run is not in full-refresh mode. Otherwise dbt builds the model in full and the guarded filter is skipped.

open as a page

In a dbt incremental model, what does the unique_key config do?

level: middleimportance: should knowfreq 62%

basics

~20 s

In dbt, unique_key names the column or columns that identify a row, so an incremental run updates or replaces matching rows in the target instead of only appending. Without it, an incremental model appends and can accumulate duplicates.

open as a page

In dbt, how do the append, merge, delete+insert and insert_overwrite incremental strategies differ?

level: seniorimportance: should knowfreq 54%

basics

~20 s

In dbt, append blindly inserts new rows; merge matches on unique_key and updates in place; delete+insert removes matching keys then reinserts; insert_overwrite replaces whole partitions present in the run. Which are available depends on the adapter.

open as a page

Why does a dbt incremental model filtered on event_at > (select max(event_at) from {{ this }}) miss late-arriving rows?

level: seniorimportance: should knowfreq 47%

basics

~20 s

That filter compares business event time against the highest event time already loaded, so any row arriving later but dated earlier falls below the watermark and is never selected. The run succeeds and the rows are silently lost forever.

open as a page

In dbt, what does the ephemeral materialization do to a model's compiled SQL?

level: middleimportance: nice to knowfreq 36%

basics

~20 s

An ephemeral dbt model creates no object in the warehouse. Its SQL is interpolated as a common table expression into every downstream model that refs it, so the logic is reusable in dbt but not queryable outside it.

open as a page