dbt Materializations

A materialization controls what dbt creates in your warehouse when it runs a model. Different materializations have different trade-offs between storage cost, query speed, and rebuild time. Choosing the right materialization for each layer of your project makes your pipeline faster and cheaper.

The Four Standard Materializations

Materialization    Warehouse Object Created    Data Stored?
--------------     ------------------------    ------------
view               SQL View                    No
table              Physical Table              Yes
incremental        Physical Table (partial)    Yes (new rows only)
ephemeral          Nothing (CTE only)          No

View

A view is a saved SQL query. When someone queries the view, the database runs the underlying SELECT at that moment. No data is stored on disk. The view always returns current results from the source tables.

When to Use Views

  • Staging models that read directly from raw source tables
  • Models that are queried rarely and do not need fast performance
  • Intermediate models that are only used by one downstream model

Pros and Cons

Pros:
  Always up to date (no stale data)
  Zero storage cost
  Fast to rebuild (just replace the view definition)

Cons:
  Slow to query on large datasets (reruns SQL every time)
  Cannot be indexed

Setting View Materialization

-- Option 1: in the model file
{{ config(materialized='view') }}
select ...

-- Option 2: in dbt_project.yml (applies to all models in staging/)
models:
  my_project:
    staging:
      +materialized: view

Table

A table copies the results of your SELECT and stores them on disk as a physical table. Every time dbt runs the model, it drops the old table and creates a new one from scratch.

When to Use Tables

  • Mart models that analysts query frequently in dashboards
  • Models that aggregate large amounts of data (aggregations are precomputed)
  • Any model where query speed matters more than rebuild time

Pros and Cons

Pros:
  Fast to query (data is precomputed and stored)
  Can be indexed for additional speed
  Predictable query performance

Cons:
  Uses warehouse storage
  Full rebuild every run (slow for very large datasets)
  Data is only as fresh as the last dbt run

Setting Table Materialization

{{ config(materialized='table') }}
select ...

Incremental

An incremental model adds only new or changed rows to an existing table instead of rebuilding the whole table. On the first run, dbt builds the full table. On subsequent runs, dbt inserts only rows that are new since the last run.

A Delivery Analogy

Table materialization:
  Every day: throw away the warehouse, rebuild it from scratch

Incremental materialization:
  Day 1: build the warehouse from scratch
  Day 2: add only yesterday's new deliveries
  Day 3: add only today's new deliveries

Basic Incremental Model

{{ config(materialized='incremental') }}

select
    order_id,
    customer_id,
    order_date,
    amount
from {{ source('ecommerce', 'orders') }}

{% if is_incremental() %}
  where order_date > (select max(order_date) from {{ this }})
{% endif %}

{% if is_incremental() %} adds the WHERE clause only on incremental runs. On the first full run, it skips the WHERE and loads all rows. {{ this }} refers to the existing incremental table itself.

When to Use Incremental

  • Event tables with millions of new rows per day
  • Log tables that grow continuously
  • Any table where a full rebuild takes too long

Ephemeral

An ephemeral model creates no database object at all. Instead, dbt inlines the model's SQL as a Common Table Expression (CTE) inside any model that references it.

A Cooking Analogy

View/Table: "Make a bowl of salsa, put it in the fridge, serve from there"
Ephemeral:  "Chop the tomatoes right into the dish being prepared"

Example

-- models/int_orders_with_flags.sql (ephemeral)
{{ config(materialized='ephemeral') }}

select
    order_id,
    amount,
    case when amount > 1000 then true else false end as is_large_order
from {{ ref('stg_orders') }}
-- models/fct_orders.sql
select
    o.order_id,
    o.is_large_order,
    c.full_name
from {{ ref('int_orders_with_flags') }} o   ← dbt inlines this as CTE
join {{ ref('stg_customers') }} c
  on o.customer_id = c.customer_id

The compiled SQL for fct_orders becomes:

with int_orders_with_flags as (
    -- contents of int_orders_with_flags.sql inlined here
    select order_id, amount,
      case when amount > 1000 then true else false end as is_large_order
    from dbt_dev.stg_orders
)

select
    o.order_id,
    o.is_large_order,
    c.full_name
from int_orders_with_flags o
join dbt_dev.stg_customers c on o.customer_id = c.customer_id

When to Use Ephemeral

  • Intermediate logic you want to keep modular but do not need to query independently
  • Small helper transformations used by exactly one downstream model

Choosing the Right Materialization

Layer           Typical Materialization    Reason
-----------     -----------------------    ------
Staging         view                       Always fresh, no storage cost
Intermediate    view or ephemeral          Rarely queried directly
Marts (small)   table                      Fast for dashboards
Marts (large)   incremental                Efficient for big datasets
Helper logic    ephemeral                  Modular, no warehouse clutter

Overriding Materializations

Set defaults in dbt_project.yml and override per model using the config block:

# dbt_project.yml – default for each folder
models:
  my_project:
    staging:
      +materialized: view
    intermediate:
      +materialized: view
    marts:
      +materialized: table
-- Override for one specific large model
{{ config(materialized='incremental') }}
select ...

The config block in the model file always wins over the folder default in dbt_project.yml.

Leave a Comment

Your email address will not be published. Required fields are marked *