Skip to main content

04 - dbt Materializations: Table, View, Incremental Models and Snapshots for Change Data Capture

dbt 17 min Materializzazioni dbt: Table, View, Modelli Incrementali e Snapshot per Change Data Capture Scegliere la materializzazione giusta cambia tutto:...

What do you think about this article?

At a Glance

dbt 17 min Materializzazioni dbt: Table, View, Modelli Incrementali e Snapshot per Change Data Capture Scegliere la materializzazione giusta cambia tutto:...

dbt 17 min

dbt Materializations: Table, View, Incremental Models and Snapshots for Change Data Capture

Choosing the right materialization changes everything: view for maximum freshness, table for performance, incremental for billions of rows (merge/append/delete+insert strategies), and snapshot for capturing the history of changes with Slowly Changing Dimensions type 2.

dbt incremental dbt snapshot dbt materializations unique_key dbt is_incremental

Why Materialization is the Most Important Decision of a dbt Model

Every dbt model must answer a question before even writing the first line of SQL: where and how will the result be physically stored? The answer determines performance, cost, data freshness and operational complexity.

dbt provides four main materializations: view, table, incremental e snapshot. Each one is useful a specific purpose, and choosing the wrong one can mean queries that take minutes instead of seconds, or computing costs increased tenfold for no reason.

This module explores each materialization in depth: internal mechanism, configuration, advanced strategies, and the criteria for choosing the right one in the real context.

View: Maximum Freshness, Zero Storage

La view is the default materialization in dbt. When you run dbt run on a model configured as a view, dbt creates (or replaces) a view SQL in the warehouse. No data is copied - the query is re-executed every time someone query the view.

-- Configurazione in dbt_project.yml (default globale)
models:
  my_project:
    staging:
      +materialized: view   # tutti i modelli in staging/ sono view
    marts:
      +materialized: table  # tutti i modelli in marts/ sono table
-- Oppure config() inline nel file .sql
{{ config(materialized='view') }}

SELECT
    order_id,
    customer_id,
    created_at,
    total_amount,
    status
FROM {{ source('raw', 'orders') }}
WHERE status != 'test_order'

When to Use Views

  • Staging Templates (stg_*): rename and cast columns, no heavy joins. Each downstream query always gets the freshest data from the source.
  • Rarely used models: it makes no sense to materialize data that comes questioned once a day by a single report.
  • Prototypes and development: Develop quickly without accumulating temporary tables.

View limit

If a view refers to another view which refers to yet another view (chain deep), each query recalculates the entire chain from the source. On warehouse with costs compute-based (BigQuery, Snowflake), this translates into high costs and high latency. Switch to table when the pattern is called frequently or stands halfway through a complex DAG.

Table: Maximum Performance, Complete Rebuild

With materialization table, dbt runs a CREATE TABLE AS SELECT to every dbt run. The table is dropped and recreated from scratch. All data comes physically written to the warehouse, so downstream queries find pre-aggregated and non-aggregated data they have to recalculate anything.

-- models/marts/finance/fct_orders_daily.sql
{{ config(
    materialized='table',
    schema='marts_finance',
    tags=['daily', 'finance']
) }}

SELECT
    DATE_TRUNC('day', o.created_at)          AS order_date,
    c.country                                 AS customer_country,
    p.category                                AS product_category,
    COUNT(o.order_id)                         AS order_count,
    SUM(o.total_amount)                       AS gross_revenue,
    SUM(o.total_amount * (1 - o.discount_pct)) AS net_revenue,
    AVG(o.total_amount)                       AS avg_order_value,
    COUNT(DISTINCT o.customer_id)             AS unique_customers
FROM {{ ref('stg_orders') }}        o
JOIN {{ ref('stg_customers') }}     c ON o.customer_id = c.customer_id
JOIN {{ ref('stg_order_items') }}   i ON o.order_id    = i.order_id
JOIN {{ ref('stg_products') }}      p ON i.product_id  = p.product_id
WHERE o.status = 'completed'
GROUP BY 1, 2, 3

Warehouse Specific Optimizations

dbt supports specific configurations for each warehouse that significantly improve the performance of materialized tables:

-- Snowflake: cluster keys per evitare full table scan
{{ config(
    materialized='table',
    cluster_by=['order_date', 'customer_country']
) }}

-- BigQuery: partition e cluster
{{ config(
    materialized='table',
    partition_by={
        'field': 'order_date',
        'data_type': 'date',
        'granularity': 'day'
    },
    cluster_by=['customer_country', 'product_category']
) }}

-- Redshift: distribution key e sort key
{{ config(
    materialized='table',
    dist='customer_id',
    sort=['order_date', 'customer_country']
) }}

Incremental: Process Only New Data

The models incremental they are dbt's answer to everyone's real problem data pipeline: how to process billions of historical rows without recalculating everything from scratch at every execution. The idea is simple - update or insert only changed records or new since the last run.

Basic Structure of an Incremental Model

-- models/marts/events/fct_page_views.sql
{{ config(
    materialized='incremental',
    unique_key='page_view_id',
    on_schema_change='sync_all_columns'
) }}

SELECT
    page_view_id,
    session_id,
    user_id,
    page_url,
    referrer_url,
    DATE_TRUNC('hour', viewed_at) AS viewed_hour,
    viewed_at,
    duration_seconds,
    device_type
FROM {{ ref('stg_page_views') }}

{% if is_incremental() %}
-- Questo blocco viene eseguito SOLO nelle run incrementali (non in full refresh)
WHERE viewed_at > (
    SELECT MAX(viewed_at)
    FROM {{ this }}
)
{% endif %}

On the first run (table not existing), dbt executes the complete query and creates the table. In subsequent runs, is_incremental() becomes true and the filter WHERE is included, processing only the new data.

The Four Incremental Strategies

The strategy defines the merge mechanism between new data and existing table. The choice depends on the warehouse and the type of updates expected:

1. append (default without unique_key)

{{ config(
    materialized='incremental',
    incremental_strategy='append'
) }}
-- Inserisce solo nuove righe, non aggiorna mai.
-- Perfetto per dati immutabili (log di eventi,
-- audit trail, time-series append-only).
-- Attenzione: i duplicati non vengono rimossi.

2. merge (default with unique_key)

{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge',
    merge_update_columns=['status', 'updated_at']
) }}
-- Upsert basato su unique_key.
-- merge_update_columns limita le colonne
-- aggiornate (ottimizzazione performance).

3. delete+insert

{{ config(
    materialized='incremental',
    unique_key=['order_id', 'order_date'],
    incremental_strategy='delete+insert',
    partition_by={
      'field': 'order_date',
      'data_type': 'date'
    }
) }}
-- Elimina le partizioni interessate e reinserisce.
-- BigQuery: efficiente perché opera per
-- partizioni intere, non riga per riga.

4. insert_overwrite

{{ config(
    materialized='incremental',
    incremental_strategy='insert_overwrite',
    partition_by={
      'field': 'event_date',
      'data_type': 'date'
    }
) }}
-- Spark/BigQuery: sovrascrive intere partizioni.
-- Atomico e idempotente per partizione.
-- Ideale per re-processing di finestre temporali.

Full Refresh: Forced Reconstruction

-- Forza la ricostruzione completa ignorando is_incremental()
-- dbt run --full-refresh --select fct_page_views

-- Utile quando:
-- 1. La logica del modello è cambiata (nuove colonne, logica diversa)
-- 2. I dati sorgente sono stati corretti retroattivamente
-- 3. Vuoi verificare che il modello sia idempotente
-- 4. Migrazione schema (on_schema_change non è sufficiente)

-- on_schema_change controlla il comportamento dbt in caso di
-- mismatch tra schema attuale e schema del modello:
{{ config(
    materialized='incremental',
    on_schema_change='sync_all_columns'
    -- opzioni: 'ignore' | 'fail' | 'append_new_columns' | 'sync_all_columns'
) }}

Advanced Pattern: Lookback Window

The filter MAX(viewed_at) does not manage i late-arriving data: events with timestamp passed that arrive late in the pipeline. The lookback window solves this:

-- models/marts/events/fct_events_hourly.sql
{{ config(
    materialized='incremental',
    unique_key=['event_id'],
    incremental_strategy='merge'
) }}

SELECT
    event_id,
    user_id,
    event_type,
    DATE_TRUNC('hour', event_timestamp) AS event_hour,
    event_timestamp,
    properties
FROM {{ ref('stg_events') }}

{% if is_incremental() %}
WHERE event_timestamp >= (
    -- Riprocessa le ultime 3 ore per catturare i late-arriving data
    SELECT DATEADD('hour', -3, MAX(event_timestamp))
    FROM {{ this }}
)
{% endif %}

Snapshot: Change Data Capture and SCD Type 2

Snapshots are the dbt mechanism for tracking changes to records over time. They implement automatically Slowly Changing Dimensions type 2 (SCD-2): whenever a record changes, the snapshot creates a new row with the effective dates and maintains the previous line as history.

Structure of a Snapshot File

-- snapshots/snap_customers.sql
-- I file snapshot vanno nella directory snapshots/ (non in models/)

{% snapshot snap_customers %}

{{ config(
    target_schema='snapshots',
    unique_key='customer_id',
    strategy='timestamp',
    updated_at='updated_at',
    invalidate_hard_deletes=True
) }}

SELECT
    customer_id,
    email,
    first_name,
    last_name,
    customer_tier,
    billing_country,
    annual_revenue,
    updated_at
FROM {{ source('raw', 'customers') }}

{% endsnapshot %}

dbt automatically adds four columns to the snapshot table:

-- Colonne aggiunte automaticamente dbt allo snapshot:
-- dbt_scd_id      : identificatore univoco della riga di storia (hash)
-- dbt_updated_at  : quando dbt ha elaborato questa riga
-- dbt_valid_from  : inizio del periodo di validità
-- dbt_valid_to    : fine del periodo di validità (NULL = record corrente)

-- Query per il valore corrente (più recente):
SELECT *
FROM {{ ref('snap_customers') }}
WHERE dbt_valid_to IS NULL

-- Query per il valore storico in un dato momento:
SELECT *
FROM {{ ref('snap_customers') }}
WHERE '2024-06-15'::date BETWEEN dbt_valid_from AND COALESCE(dbt_valid_to, '9999-12-31')

-- Query per vedere tutta la storia di un cliente:
SELECT
    customer_id,
    customer_tier,
    dbt_valid_from,
    dbt_valid_to,
    CASE WHEN dbt_valid_to IS NULL THEN 'current' ELSE 'historical' END AS record_status
FROM {{ ref('snap_customers') }}
WHERE customer_id = 42
ORDER BY dbt_valid_from

Check vs timestamp strategy

-- Strategia timestamp (raccomandata): usa una colonna updated_at
-- La sorgente DEVE avere una colonna di timestamp affidabile
{% snapshot snap_orders %}
{{ config(
    target_schema='snapshots',
    unique_key='order_id',
    strategy='timestamp',
    updated_at='updated_at'
) }}
SELECT order_id, customer_id, status, total_amount, updated_at
FROM {{ source('raw', 'orders') }}
{% endsnapshot %}

-- Strategia check: confronta i valori di colonne specifiche
-- Utile quando la sorgente non ha un campo updated_at
{% snapshot snap_product_prices %}
{{ config(
    target_schema='snapshots',
    unique_key='product_id',
    strategy='check',
    check_cols=['price', 'discount_pct', 'category']
    -- oppure check_cols='all' per confrontare tutte le colonne
) }}
SELECT product_id, product_name, price, discount_pct, category
FROM {{ source('raw', 'products') }}
{% endsnapshot %}

How to Use Snapshots in Downstream Models

-- models/marts/dim_customers_scd2.sql
-- Dimensione con storia completa per l'analisi storica

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

WITH customer_history AS (
    SELECT
        customer_id,
        email,
        first_name || ' ' || last_name   AS full_name,
        customer_tier,
        billing_country,
        dbt_valid_from                    AS valid_from,
        COALESCE(dbt_valid_to, '9999-12-31'::date) AS valid_to,
        dbt_valid_to IS NULL              AS is_current,
        dbt_scd_id                        AS surrogate_key
    FROM {{ ref('snap_customers') }}
)
SELECT * FROM customer_history

-- Ora puoi fare join storici precisi:
-- JOIN dim_customers_scd2 c
--   ON f.customer_id = c.customer_id
--   AND f.order_date BETWEEN c.valid_from AND c.valid_to

Ephemeral: Inline CTE without Persistence

A lesser-known but useful fifth materialization: ephemeral. A model ephemeral does not create any objects in the warehouse - it is injected as a CTE into the model which references it via ref().

-- models/staging/util/base_date_spine.sql
{{ config(materialized='ephemeral') }}

-- Questo modello diventa una CTE inline ovunque sia referenziato
-- Nessuna tabella o view viene creata nel warehouse
SELECT
    DATEADD('day', seq4(), '2020-01-01'::date) AS date_day
FROM TABLE(GENERATOR(ROWCOUNT => 3650))  -- 10 anni di date (Snowflake)

When to Use Ephemeral

  • Intermediate transformation logic that has no standalone value
  • Avoid the proliferation of temporary tables in the warehouse
  • Helpers shared between a few models (given spines, sequences)

Attention: ephemeral models do not appear in the lineage graph e they cannot be tested directly. Use them sparingly.

Guide to Choosing the Right Materialization

Here is a practical decision framework based on query frequency, data volume and type of updates:

Use VIEW when:

  • The model is a staging layer (stg_*)
  • Queries are rare (less than 10 per day)
  • Upstream volume is small (<10M rows)
  • You need absolute freshness
  • Are you prototyping or developing

Use TABLE when:

  • The model is queried frequently
  • The query includes expensive joins or heavy aggregations
  • Volume is manageable (<100M rows)
  • The complete daily run fits within the budget
  • BI dashboards that need to respond in <2 seconds

Use INCREMENTAL when:

  • Volume >100M rows (table would be too slow)
  • Data grows continuously (events, logs, transactions)
  • You need more frequent updates (every hour)
  • The cost of full refresh is prohibitive

Use SNAPSHOT when:

  • You have dimensions that change over time (customers, prices)
  • You have to answer historical questions (“what was the customer tier in June?”)
  • Implement SCD Type 2 on dimension tables
  • The source has no native historicization

Combine Materializations in the Real DAG

A mature dbt design uses different materializations in different layers. Here's an example of typical architecture with the Medallion pattern (Bronze/Silver/Gold):

# dbt_project.yml - configurazione materializzazioni per layer
models:
  my_project:
    staging:
      +materialized: view         # staging: view, sempre fresh
      +schema: staging

    intermediate:
      +materialized: ephemeral    # join intermedi: CTE, nessun overhead

    marts:
      finance:
        +materialized: table      # report finance: aggregazioni pre-calcolate
        +schema: marts_finance
      events:
        +materialized: incremental # eventi: troppi per ricostruire ogni volta
        +schema: marts_events
      dimensions:
        +materialized: table      # dimensioni: piccole, rebuilt giornalmente
        +schema: marts_dim

snapshots:
  my_project:
    +target_schema: snapshots     # snapshot: schema dedicato

Conclusions and Next Steps

Materializations are the mechanism by which DBT balances freshness, performance and cost. There is no universal answer: each model must be evaluated based on the volume of data, to the update rate and downstream access pattern.

The rule of thumb: start with view in staging, move on to table in marts when queries are slow, and use incremental only when the volume makes complete reconstruction economically impractical.

The next module addresses the data quality: like dbt test ensures that the models produce correct data, from the built-in generic tests (unique, not_null) to custom tests with dbt-expectations.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects