Salta al contenuto principale

04 - Materializzazioni dbt: Table, View, Modelli Incrementali e Snapshot per Change Data Capture

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

Cosa ne pensi di questo articolo?

04 - Materializzazioni dbt: Table, View, Modelli Incrementali e Snapshot per Change Data Capture
dbtFoto:SiddePexels

In Breve

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

dbt 17 min

Materializzazioni dbt: Table, View, Modelli Incrementali e Snapshot per Change Data Capture

Scegliere la materializzazione giusta cambia tutto: view per freshness massima, table per performance, incremental per miliardi di righe (strategie merge/append/delete+insert), e snapshot per catturare la storia delle modifiche con Slowly Changing Dimensions tipo 2.

dbt incremental dbt snapshot materializzazioni dbt unique_key dbt is_incremental

Perché la Materializzazione è la Decisione più Importante di un Modello dbt

Ogni modello dbt deve rispondere a una domanda prima ancora di scrivere la prima riga di SQL: dove e come verrà fisicamente archiviato il risultato? La risposta determina performance, costo, freshness dei dati e complessità operativa.

dbt mette a disposizione quattro materializzazioni principali: view, table, incremental e snapshot. Ognuna serve uno scopo preciso, e scegliere quella sbagliata può significare query che impiegano minuti invece di secondi, oppure costi di compute decuplicati senza ragione.

Questo modulo esplora in profondità ciascuna materializzazione: meccanismo interno, configurazione, strategie avanzate, e i criteri per scegliere quella giusta nel contesto reale.

View: Massima Freshness, Zero Storage

La view è la materializzazione di default in dbt. Quando esegui dbt run su un modello configurato come view, dbt crea (o sostituisce) una vista SQL nel warehouse. Nessun dato viene copiato: la query viene rieseguita ogni volta che qualcuno interroga la 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'

Quando Usare le View

  • Modelli di staging (stg_*): rinominano e castano colonne, nessun join pesante. Ogni query downstream ottiene sempre i dati più freschi dalla sorgente.
  • Modelli usati raramente: non ha senso materializzare dati che vengono interrogati una volta al giorno da un solo report.
  • Prototipi e sviluppo: sviluppa velocemente senza accumulare tabelle temporanee.

Limite delle View

Se una view fa riferimento a un'altra view che fa riferimento a un'altra view ancora (catena profonda), ogni query ricalcola tutta la catena dalla sorgente. Su warehouse con costi basati sul compute (BigQuery, Snowflake), questo si traduce in costi elevati e latenza alta. Passa a table quando il modello è chiamato frequentemente o sta a metà di un DAG complesso.

Table: Performance Massima, Ricostruzione Completa

Con la materializzazione table, dbt esegue un CREATE TABLE AS SELECT ad ogni dbt run. La tabella viene eliminata e ricreata da zero. Tutti i dati vengono fisicamente scritti nel warehouse, quindi le query downstream trovano dati pre-aggregati e non devono ricalcolare nulla.

-- 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

Ottimizzazioni Specifiche per Warehouse

dbt supporta configurazioni specifiche per ogni warehouse che migliorano sensibilmente le performance delle table materializzate:

-- 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: Processa Solo i Dati Nuovi

I modelli incrementali sono la risposta di dbt al problema reale di ogni pipeline di dati: come processare miliardi di righe storiche senza ricalcolare tutto da zero ad ogni esecuzione. L'idea è semplice - aggiorna o inserisci solo i record cambiati o nuovi dall'ultima esecuzione.

Struttura Base di un Modello Incrementale

-- 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 %}

Al primo run (tabella non esistente), dbt esegue la query completa e crea la tabella. Nelle run successive, is_incremental() diventa true e il filtro WHERE viene incluso, processando solo i nuovi dati.

Le Quattro Strategie Incrementali

La strategia definisce il meccanismo di merge tra nuovi dati e tabella esistente. La scelta dipende dal warehouse e dal tipo di aggiornamenti attesi:

1. append (default senza 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 con 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: Ricostruzione Forzata

-- 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'
) }}

Pattern Avanzato: Lookback Window

Il filtro MAX(viewed_at) non gestisce i late-arriving data: eventi con timestamp passato che arrivano in ritardo nella pipeline. La lookback window risolve questo:

-- 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 e SCD Type 2

Gli snapshot sono il meccanismo dbt per tracciare le modifiche ai record nel tempo. Implementano automaticamente le Slowly Changing Dimensions di tipo 2 (SCD-2): ogni volta che un record cambia, lo snapshot crea una nuova riga con le date di validità e mantiene la riga precedente come storia.

Struttura di un File Snapshot

-- 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 aggiunge automaticamente quattro colonne alla tabella snapshot:

-- 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

Strategia check vs timestamp

-- 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 %}

Come Usare gli Snapshot nei Modelli Downstream

-- 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: CTE Inline senza Persistenza

Una quinta materializzazione meno nota ma utile: ephemeral. Un modello ephemeral non crea nessun oggetto nel warehouse - viene iniettato come CTE nel modello che lo referenzia tramite 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)

Quando Usare Ephemeral

  • Logica di trasformazione intermedia che non ha valore standalone
  • Evitare la proliferazione di tabelle temporanee nel warehouse
  • Helper condivisi tra pochi modelli (date spine, sequenze)

Attenzione: i modelli ephemeral non appaiono nel lineage graph e non possono essere testati direttamente. Usali con parsimonia.

Guida alla Scelta della Materializzazione Giusta

Ecco un framework decisionale pratico basato su frequenza di query, volume dati e tipo di aggiornamenti:

Usa VIEW quando:

  • Il modello è uno staging layer (stg_*)
  • Le query sono rare (meno di 10 al giorno)
  • Il volume upstream è piccolo (<10M righe)
  • Hai bisogno di freshness assoluta
  • Stai prototipando o sviluppando

Usa TABLE quando:

  • Il modello è interrogato frequentemente
  • La query include join costosi o aggregazioni pesanti
  • Il volume è gestibile (<100M righe)
  • Il run giornaliero completo ci sta nel budget
  • Dashboard BI che devono rispondere in <2 secondi

Usa INCREMENTAL quando:

  • Volume >100M righe (table sarebbe troppo lenta)
  • I dati crescono continuamente (eventi, log, transazioni)
  • Hai bisogno di aggiornamenti più frequenti (ogni ora)
  • Il costo di full refresh è proibitivo

Usa SNAPSHOT quando:

  • Hai dimensioni che cambiano nel tempo (clienti, prezzi)
  • Devi rispondere a domande storiche (“qual era il tier del cliente a giugno?”)
  • Implementi SCD Type 2 su tabelle di dimensione
  • La sorgente non ha storicizzazione nativa

Combinare le Materializzazioni nel DAG Reale

Un progetto dbt maturo usa materializzazioni diverse in strati diversi. Ecco un esempio di architettura tipica con il pattern Medallion (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

Conclusioni e Prossimi Passi

Le materializzazioni sono il meccanismo con cui dbt bilancia freshness, performance e costo. Non esiste una risposta universale: ogni modello va valutato in base al volume dei dati, alla frequenza di aggiornamento e al pattern di accesso downstream.

La regola pratica: inizia con view in staging, passa a table in marts quando le query sono lente, e usa incremental solo quando il volume rende la ricostruzione completa economicamente impraticabile.

Il prossimo modulo affronta la qualità dei dati: come dbt test garantisce che i modelli producano dati corretti, dai test generici built-in (unique, not_null) ai test personalizzati con dbt-expectations.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti