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

Commenti
Caricamento commenti...
Accedi per commentare