Module d'onboarding — dbt (data build tool)
Contenu pédagogique produit par Bill (QA). Destiné à être intégré dans onboarding.fastodata.com.
Public : ingénieur data avec SQL solide. Format : théorie d'abord → exercices → quiz corrigé.
Stack de référence de l'équipe : dbt + BigQuery (+ orchestration Kestra/Airflow).
Durée estimée : ~3-4 h de lecture active + exercices.
Objectifs pédagogiques
À la fin de ce module, l'apprenant sait :
- Expliquer le rôle de dbt (le « T » de l'ELT) et ce qu'il ne fait pas.
- Construire un lignage avec
ref()/source()et lire un DAG. - Choisir la bonne matérialisation (view / table / incremental / ephemeral).
- Écrire tests et documentation en YAML.
- Utiliser Jinja & macros, et débugger avec
dbt compile. - Comprendre snapshots (SCD2), sources/freshness, packages.
Module 1 — Ce qu'est dbt (et ce qu'il n'est pas)
dbt compile du SQL et l'exécute dans le data warehouse (BigQuery ici). Il ne stocke ni ne calcule rien lui-même : l'entrepôt fait le travail, dbt orchestre.
Apports au-dessus du SQL brut :
- Dépendances automatiques via
ref()→ DAG + ordre d'exécution. - Tests et doc versionnés avec le code (git).
- Réutilisation via Jinja/macros.
- Environnements (dev/prod) via les
targetduprofiles.yml.
⚠️ Piège mental : un modèle dbt n'est pas un script
CREATE TABLE. On écrit uniquement leSELECT; dbt génère leCREATE … ASselon la matérialisation.
Module 2 — ref(), source() et le DAG
{{ source('raw', 'sales') }}→ table brute externe déclarée dans un YAMLsources. Porte d'entrée de la donnée.{{ ref('stg_sales') }}→ un autre modèle dbt. Jamais le nom de table en dur.
dbt lit tous les ref()/source(), déduit le DAG, et ordonne l'exécution automatiquement.
Sélection par graphe :
dbt run --select stg_sales+→ ce modèle et tout son aval.dbt run --select +mart_revenue→ le modèle et tout son amont.dbt run --select +int_orders+→ amont et aval.
Architecture en couches (standard équipe) : staging (stg_) → intermediate (int_) → publish/marts.
- staging : 1 modèle/source, nettoyage/renommage/cast, matérialisé
view. - intermediate : jointures, logique métier réutilisable.
- marts : tables finales BI, matérialisé
table.
Module 3 — Les matérialisations
| Type | Génère | Quand |
|---|---|---|
| view | CREATE VIEW |
staging, transfo légère |
| table | CREATE TABLE AS (full refresh) |
marts lus souvent, calcul lourd |
| incremental | insère/merge seulement le nouveau | grosses tables append-only |
| ephemeral | pas d'objet ; inliné en CTE chez l'aval | logique intermédiaire jamais requêtée seule |
Incremental (clé sur BigQuery) :
{{ config(materialized='incremental', unique_key='order_id') }}
select * from {{ source('raw','orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
is_incremental()= vrai seulement si la table existe ET pas en--full-refresh.{{ this }}= le modèle lui-même.unique_key→ merge au lieu d'insert (évite les doublons).
Module 4 — Tests & documentation (YAML)
models:
- name: stg_sales
description: "Ventes nettoyées, une ligne par transaction"
columns:
- name: sale_id
tests: [unique, not_null]
- name: customer_id
tests:
- not_null
- relationships: { to: ref('stg_customers'), field: customer_id }
- name: status
tests:
- accepted_values: { values: ['paid','refunded','pending'] }
4 tests génériques natifs : unique, not_null, relationships, accepted_values.
Au-delà → package dbt-utils ou tests singuliers (.sql dans tests/ qui doit renvoyer 0 ligne).
dbt build = run + test dans l'ordre du DAG (le réflexe vs run seul).
Module 5 — Jinja & macros
{{ ... }}= expression (produit du SQL injecté).{% ... %}= instruction (logique).- Variables :
{{ var("start_date", "2024-01-01") }}. - Boucle (pivot manuel) :
{% for s in ['paid','refunded','pending'] %}
sum(case when status = '{{ s }}' then amount end) as amount_{{ s }}{{ "," if not loop.last }}
{% endfor %}
- Macro (fonction SQL réutilisable, dans
macros/) :
{% macro cents_to_euros(column) %}round({{ column }} / 100.0, 2){% endmacro %}
Réflexe debug n°1 :
dbt compile→ SQL final danstarget/compiled/. Toujours le 1er endroit à regarder.
Piège : Jinja s'exécute à la compilation, jamais au runtime. Il ne voit pas les données. Pour boucler sur des valeurs en base →run_query()/statement(introspection).
Module 6 — Snapshots (SCD type 2)
Historiser les changements d'un enregistrement dans le temps (ex. une commande qui change de statut).
{% snapshot orders_snapshot %}
{{ config(target_schema='snapshots', unique_key='order_id',
strategy='timestamp', updated_at='updated_at') }}
select * from {{ source('raw','orders') }}
{% endsnapshot %}
strategy='timestamp'→ détecte un changement via une colonne de date.strategy='check'→ compare une liste de colonnes.- dbt gère
dbt_valid_from/dbt_valid_to→ tu retrouves l'état à n'importe quelle date.
Module 7 — Sources, freshness & packages
- Sources déclarées en YAML →
source(), doc, et tests de fraîcheur :
sources:
- name: raw
tables:
- name: orders
loaded_at_field: _loaded_at
freshness:
warn_after: { count: 12, period: hour }
error_after: { count: 24, period: hour }
dbt source freshness → alerte si la donnée brute est trop vieille.
- Packages (
packages.yml+dbt deps) :dbt-utils(tests/macros),codegen,dbt-expectations.
Exercices (fil rouge : un mini-pipeline ventes)
- Staging : écris
stg_ordersà partir desource('raw','orders')— casteamount_centsen int, renommets→order_at. Matérialisation ? Justifie. - Tests : ajoute le YAML —
order_idunique+not_null,status∈ {paid,refunded,pending},customer_iden relation avecstg_customers. - Incremental : transforme
fct_orders(10 M lignes, append-only) en incremental surorder_idfiltré parorder_at. Écris le blocis_incremental(). - Macro : crée
cents_to_euros()et utilise-la dansfct_orders. - Sélection : quelle commande pour rejouer
fct_ordersET tout ce qui en dépend, sans toucher l'amont ?
Quiz final corrigé
Q1. Pourquoi {{ ref('x') }} plutôt qu'un nom de table en dur ?
→ Construit le DAG/ordre auto ; environment-aware (dev/prod) ; refactor sûr.
Q2. Table d'événements 2 Md append-only : matérialisation + rôle de is_incremental() ?
→ incremental (sinon rebuild complet = lent/cher). is_incremental() n'est vrai que si la table existe et hors --full-refresh ; il borne le WHERE aux nouvelles lignes tout en autorisant le rebuild complet.
Q3. relationships vs accepted_values ?
→ relationships = intégrité référentielle (valeur existe dans une autre table). accepted_values = appartenance à une liste figée (contrainte de domaine).
Q4. dbt run --select +int_orders exécute quoi ?
→ int_orders et tout son amont (le + devant = ancêtres). Pas l'aval.
Q5. ephemeral : à quoi ça sert, qu'est-ce qui est créé en base ?
→ Factoriser une logique intermédiaire jamais requêtée seule ; aucun objet créé, dbt l'inline en CTE chez l'aval.
Q6. {% for %} s'exécute avant ou pendant la requête BigQuery ? Conséquence ?
→ Avant (compilation Jinja). Conséquence : ne peut pas dépendre d'une valeur de ligne ; pour du dynamique-données → run_query().
Q7. Modèle au comportement bizarre, plein de Jinja. Première commande ?
→ dbt compile, puis lire target/compiled/… pour voir le SQL réel envoyé.
Pour aller plus loin
- Doc officielle dbt (concepts, materializations, tests).
- Normes DBT internes (Confluence, espace BI — page « Normes DBT »).
- Skills équipe :
dbt,dbt-modeling,bigquery,kestra.