Torna al blog
dbt: Modellare, Testare e Documentare i Dati nel Data Warehouse

dbt: Modellare, Testare e Documentare i Dati nel Data Warehouse

9 min di lettura1 visualizzazioni
di Marco Salmaso
dbt trasforma le query SQL sparse in un progetto versionato: modelli con dipendenze esplicite, test automatici, documentazione e lineage. Una guida pratica a cosa fa davvero, alle materializzazioni, ai test e ai limiti dello strumento.

Chi lavora con i dati in azienda conosce bene una scena ricorrente: decine di query SQL salvate in cartelle diverse, copie di copie, un file chiamato query_finale_v3_definitiva.sql di cui nessuno si fida più del tutto. Ogni volta che si cambia qualcosa a monte, il timore è che qualcosa a valle si rompa silenziosamente. dbt nasce esattamente per rispondere a questo problema: prendere il SQL che già scriviamo e trasformarlo in un progetto versionato, con dipendenze esplicite, test e documentazione.

In questo articolo vediamo cosa fa dbt in pratica, come si struttura un progetto, quali sono le scelte da fare e — soprattutto — quando non serve.

Il problema: SQL senza contratto

In un flusso ELT tipico i dati arrivano grezzi nel warehouse (BigQuery, Snowflake, PostgreSQL, DuckDB, Redshift...) e da lì vanno trasformati in tabelle leggibili: fatturato per cliente, giacenze per negozio, margini per categoria. Questa fase di trasformazione è quasi sempre fatta in SQL, ed è lì che si concentra la complessità.

Il problema non è scrivere la singola query: è gestirne centinaia. Senza struttura ci si ritrova con:

  • dipendenze invisibili: una tabella dipende da un'altra, ma nessuno lo sa finché qualcosa non si rompe;
  • nessun test: un join che duplica righe può passare inosservato per mesi;
  • nessuna documentazione: la definizione di «cliente attivo» cambia a seconda di chi ha scritto la query;
  • nessun ambiente: si lavora direttamente in produzione perché non esiste un posto separato dove provare le modifiche.

dbt affronta questi punti con un'idea semplice: il SQL resta SQL, ma diventa un artefatto di un progetto software.

Cosa fa dbt, in una frase

dbt è uno strumento di trasformazione dati che legge file SQL, ne ricostruisce le dipendenze e li esegue nel warehouse nell'ordine corretto. Non estrae dati dalle sorgenti, non è un orchestratore di job e non è un database. Fa la «T» della sigla ELT, e la fa con gli strumenti dello sviluppo software: versionamento, test, ambienti separati, compilazione.

Il punto chiave è che dbt non introduce un nuovo linguaggio: si scrivono SELECT, e dbt si occupa di tutto il resto. La funzione ref() è il cuore di questo meccanismo.

Modelli: un file, una tabella

In un progetto dbt ogni modello è un file .sql contenente una SELECT. Il nome del file diventa il nome della tabella o vista nel warehouse.

-- models/staging/stg_ordini.sql
select
    id_ordine,
    id_cliente,
    cast(data_ordine as date) as data_ordine,
    importo_totale,
    stato
from {{ source('gestionale', 'ordini') }}
where stato != 'annullato'

La macro source() dichiara che questa tabella arriva da una sorgente esterna, definita altrove in un file di configurazione. Questo permette a dbt di sapere cosa è «dato di input» e cosa è «dato prodotto»: una distinzione che sembra banale ma che nella pratica chiarisce moltissimo.

A valle, un modello di business può costruire sopra il precedente usando ref():

-- models/marts/fatturato_per_cliente.sql
select
    c.id_cliente,
    c.nome,
    sum(o.importo_totale) as fatturato_totale,
    count(*) as numero_ordini
from {{ ref('stg_ordini') }} o
join {{ ref('dim_clienti') }} c using (id_cliente)
group by 1, 2

ref() non è solo una scorciatoia per scrivere il nome della tabella: è ciò che permette a dbt di costruire il grafo delle dipendenze. Se dim_clienti viene da un altro modello ancora, dbt sa che deve eseguirlo prima, senza che nessuno debba scrivere a mano l'ordine di esecuzione. È il motivo per cui il progetto scala bene: aggiungere un modello significa aggiungere un nodo, non riscrivere un cron job.

La convenzione più diffusa organizza i modelli su tre livelli: staging (pulizia e rinominazione delle sorgenti), intermediate (join e logica riutilizzabile), marts (tabelle finali per l'analisi o per il reporting). Non è una regola di dbt, ma una convenzione che aiuta a capire dove mettere le cose.

Materializzazioni: view, table, incremental

Ogni modello può essere materializzato in modi diversi. La scelta ha conseguenze concrete su costi, tempi e freschezza del dato.

MaterializzazioneCosa creaQuando usarla
viewuna vistamodelli leggeri, logica intermedia, dati sempre freschi
tableuna tabella fisicamodelli costosi da calcolare, letti spesso
incrementaltabella aggiornata a pezzitabelle grandi che crescono nel tempo
ephemeralnessun oggetto, solo CTEpassaggi intermedi che non serve esporre

La materializzazione si dichiara in testa al modello:

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

select * from {{ ref('stg_ordini') }}

L'incremental merita una nota a parte, perché è quello che dà più problemi se usato male:

{{ config(materialized='incremental', unique_key='id_ordine') }}

select *
from {{ ref('stg_ordini') }}
{% if is_incremental() %}
where data_ordine > (select max(data_ordine) from {{ this }})
{% endif %}

Il blocco is_incremental() viene incluso solo quando la tabella esiste già: alla prima esecuzione si carica tutto, dalle successive solo il delta. Il vantaggio è evidente su tabelle con milioni di righe. Il rischio è altrettanto evidente: se il filtro è sbagliato, o se arrivano dati in ritardo con una data precedente all'ultimo caricamento, quelle righe non entreranno mai nel modello. Un unique_key sbagliato può generare duplicati silenziosi.

La regola pratica: usare l'incremental solo quando serve davvero, e quando il criterio di filtro è un campo che non torna mai indietro (un timestamp di inserimento, non una data di business).

Test: definire le aspettative sui dati

dbt include un sistema di test dichiarativi, definiti in file YAML affiancati ai modelli.

# models/staging/schema.yml
models:
  - name: stg_ordini
    columns:
      - name: id_ordine
        tests:
          - unique
          - not_null
      - name: stato
        tests:
          - accepted_values:
              values: ['nuovo', 'spedito', 'consegnato', 'annullato']

I test generici disponibili di serie sono quattro, e coprono già una buona parte dei casi reali:

TestVerifica
uniquenessun valore duplicato nella colonna
not_nullnessun valore mancante
accepted_valuesi valori appartengono a un insieme noto
relationshipsogni valore esiste in un'altra tabella (integrità referenziale)

A questi si aggiungono i test singolari: semplici query SQL che restituiscono le righe che violano un'aspettativa. Se la query non restituisce nulla, il test passa.

-- tests/importo_non_negativo.sql
select id_ordine
from {{ ref('stg_ordini') }}
where importo_totale < 0

Questo approccio è più utile di quanto sembri. Non serve a dimostrare che il codice è corretto in senso astratto: serve a far fallire la pipeline subito, quando un'assunzione smette di valere. Un cambiamento nel gestionale che introduce uno stato ordine nuovo rompe il test accepted_values e qualcuno se ne accorge il giorno stesso, non tre mesi dopo guardando un report strano.

Documentazione e lineage

Le stesse descrizioni scritte in YAML diventano la documentazione del progetto. Con:

dbt docs generate
dbt docs serve

dbt produce un sito locale con la descrizione di ogni modello e colonna, e soprattutto il grafo di lineage: si vede a colpo d'occhio da quali sorgenti dipende una tabella finale e quali modelli si romperebbero modificandola.

Nella pratica il lineage è la funzione che convince più di tutte le altre, perché risponde a una domanda che in ogni team dati viene posta continuamente: «se cambio questo, cosa si rompe?».

Un altro strumento utile è il contratto sui modelli: si possono dichiarare nome e tipo attesi delle colonne, in modo che una modifica di schema a monte faccia fallire la build invece di propagarsi silenziosamente.

Ambienti separati e integrazione continua

dbt si connette al warehouse tramite un file profiles.yml, che definisce i target. La pratica standard è avere uno schema di sviluppo personale e uno schema di produzione, così che ognuno possa eseguire i propri modelli senza sovrascrivere le tabelle usate dai report.

dbt run --target dev
dbt build --target prod

Il comando dbt build è spesso preferibile a dbt run: esegue modelli e test insieme, interrompendo la catena sui rami che falliscono invece di propagare dati sbagliati.

Sui repository si aggiunge una pipeline di integrazione continua che, a ogni pull request, esegue solo i modelli modificati e i loro discendenti:

dbt deps
dbt build --select state:modified+ --defer --state ./prod-manifest

Il flag state:modified+ seleziona i modelli cambiati e tutto ciò che sta a valle; --defer fa sì che i modelli non modificati vengano letti dall'ambiente di produzione invece che ricalcolati. È il modo più efficace per avere feedback rapidi senza ricalcolare l'intero progetto a ogni commit.

Orchestrazione: dbt non basta

dbt esegue il grafo, ma non decide quando. Per una schedulazione seria serve un orchestratore: Airflow, Dagster, Prefect o anche solo un cron per i casi semplici. La divisione dei ruoli è chiara: l'orchestratore gestisce il tempo e le dipendenze tra sistemi esterni, dbt gestisce la logica di trasformazione e le dipendenze interne al warehouse.

È un punto che viene spesso confuso. dbt non è un sostituto di Airflow, e Airflow non è un sostituto di dbt: lavorano su piani diversi.

Quando dbt non serve

Sarebbe disonesto presentarlo come la risposta a tutto. Ci sono casi in cui è chiaramente sovradimensionato:

  • un singolo script che gira una volta al giorno su poche tabelle: un file SQL schedulato è più semplice e non richiede infrastruttura;
  • trasformazioni fortemente procedurali, dove la logica è un algoritmo più che una serie di join: in quel caso Python nel warehouse o un job dedicato può essere più adatto;
  • team di una persona sola che non ha problemi di manutenzione: il valore di test e lineage cresce con il numero di persone e di modelli.

C'è anche un costo organizzativo da mettere in conto: dbt richiede disciplina. Se i modelli vengono scritti senza criterio, il risultato è lo stesso disordine di prima, solo dentro una cartella con un nome più elegante.

Conclusione

I punti che contano, in sintesi:

  • dbt è uno strumento di trasformazione, non di estrazione né di orchestrazione: fa la T di ELT, bene e con gli strumenti del software.
  • La funzione ref() è il cuore del sistema: rende esplicite le dipendenze e permette a dbt di costruire l'ordine di esecuzione da solo.
  • La scelta della materializzazione è una decisione di costo e correttezza, non un dettaglio. L'incremental è potente e insidioso in parti uguali.
  • I test non dimostrano che tutto va bene: fanno fallire in fretta le assunzioni che non valgono più. È esattamente quello che serve in produzione.
  • Documentazione e lineage rispondono alla domanda più frequente di ogni team dati: cosa si rompe se cambio questo?
  • Serve un orchestratore a monte, e serve disciplina dentro. dbt amplifica la qualità di un progetto, non la crea dal nulla.

Foto di copertina di selcuk sarikoz su Unsplash.