J'ai hérité d'un modèle DBT incremental qui tournait sur une table Snowflake de 2,3 milliards de lignes. Le dbt run durait 38 minutes, scannait 1,8 To à chaque exécution, et coûtait 14 crédits par run. Le développeur original avait juste mis materialized='incremental' sans unique_key, sans incremental_predicates, et sans stratégie. Le résultat : des doublons silencieux, une facture qui explose, et un full_refresh qui dure 6 heures.
Voici le guide que j'aurais aimé avoir avant de toucher à ce modèle.

Le problème : pourquoi tout le monde se plante avec incremental
Le pattern naïf, tout le monde l'a écrit :
-- ⛔ Ce que tout le monde fait au début
{{ config(materialized='incremental') }}
SELECT *
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date >= (SELECT MAX(event_date) FROM {{ this }})
{% endif %}
Ça marche pour 10 millions de lignes. À 2 milliards, trois choses cassent :
- Le scan de destination : sans
incremental_predicates, DBT fait unMERGEqui scanne toute la table de destination pour trouver les lignes à mettre à jour. Sur 2 milliards de lignes, c'est 1,8 To scanné à chaque run. - Les doublons silencieux : sans
unique_key, leMERGEne sait pas quelles lignes remplacer. Il insère en doublon si la source contient des enregistrements déjà présents. - Le full_refresh qui tue : quand le schéma change (nouvelle colonne), un
--full-refreshreconstruit toute la table. Sur 2 milliards de lignes, c'est 6 heures de pipeline.
Les 4 stratégies incrementales sur Snowflake
Snowflake supporte 4 stratégies via DBT. Choisir la mauvaise = crédits grillés.
1. append — le plus simple, le plus dangereux
{{ config(
materialized='incremental',
incremental_strategy='append'
) }}
SELECT *
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date > (SELECT MAX(event_date) FROM {{ this }})
{% endif %}
Quand l'utiliser : données immuables, append-only (logs, events, telemetry). Aucun enregistrement n'est jamais modifié après insertion.
Le piège : si la source contient des doublons ou des enregistrements modifiés, ils sont insérés tels quels. Pas de dédoublonnage. Pas de mise à jour.
Coût : faible — un simple INSERT INTO sans scan de la destination.
2. merge — le standard, mais sans prédicats il vous ruine
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='event_id'
) }}
SELECT *
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date >= DATEADD(day, -1, CURRENT_DATE())
{% endif %}
DBT génère un MERGE INTO : les nouveaux enregistrements sont insérés, les existants sont mis à jour.
Quand l'utiliser : données qui peuvent changer (commandes, statuts, profils clients).
Le piège : sans incremental_predicates, le MERGE scan toute la table de destination pour matcher le unique_key. Sur 2 milliards de lignes, c'est un full scan à chaque run.
3. delete+insert — le meilleur compromis sur Snowflake
{{ config(
materialized='incremental',
incremental_strategy='delete+insert',
unique_key='event_date'
) }}
SELECT *
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date >= DATEADD(day, -7, CURRENT_DATE())
{% endif %}
DBT supprime les lignes de la période concernée, puis insère les nouvelles. Pas de MERGE, pas de scan full table sur le unique_key.
Quand l'utiliser : quand vous rechargez une partition entière (un jour, une heure) qui peut contenir des modifications. Typique pour des events qui sont reçus en batch et peuvent être corrigés.
Le piège : le unique_key doit correspondre à la granularité de la partition. Si vous utilisez event_date comme clé, la suppression efface toutes les lignes de ce jour puis réinsère. Si la source ne contient pas toutes les lignes du jour, vous perdez des données.
4. insert_overwrite — le reset par partition
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={'field': 'event_date', 'data_type': 'date'}
) }}
SELECT *
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date >= DATEADD(day, -1, CURRENT_DATE())
{% endif %}
DBT écrase les partitions existantes avec les nouvelles données. Pas de unique_key nécessaire — la partition entière est remplacée.
Quand l'utiliser : données partitionnées par date où chaque partition est self-contained (pas de jointures cross-partition).
Le piège : si la source ne contient pas toutes les données d'une partition, les données manquantes sont perdues.
Tableau de décision
| Critère | append | merge | delete+insert | insert_overwrite |
|---|---|---|---|---|
| Données immuables | ✅ | ✅ | ✅ | ✅ |
| Données modifiables | ❌ | ✅ | ✅ | ✅ |
| Dédoublonnage automatique | ❌ | ✅ | ✅ | ✅ |
| Scan full table destination | ❌ | ⚠️ Sans prédicats | ❌ | ❌ |
| Coût Snowflake | Faible | Élevé sans prédicats | Moyen | Faible |
| Complexité | Minimale | Moyenne | Moyenne | Faible |
| Risque de perte de données | Aucun | Aucun | ⚠️ Si partition incomplète | ⚠️ Si partition incomplète |
incremental_predicates : le sauveur du merge
C'est la config qui sépare les juniors des seniors sur DBT + Snowflake. Sans prédicats, le MERGE scan toute la destination. Avec, il ne scanne que la période concernée.
-- ✅ merge avec incremental_predicates : scan limité à 7 jours
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='event_id',
incremental_predicates=[
"DBT_INTERNAL_DEST.event_date >= DATEADD(day, -7, CURRENT_DATE())"
]
) }}
SELECT
event_id,
event_date,
event_type,
payload
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date >= DATEADD(day, -7, CURRENT_DATE())
{% endif %}
Attention : le filtre source (WHERE event_date >= ...) et le prédicat de destination (DBT_INTERNAL_DEST.event_date >= ...) doivent couvrir la même période. Si la source charge 7 jours mais le prédicat n'en filtre que 1, le MERGE va chercher des lignes dans la destination qui ne seront jamais matchées.
Impact mesuré
Sur le modèle de 2,3 milliards de lignes dont j'ai hérité :
| Config | Scan destination | Temps dbt run | Crédits/run |
|---|---|---|---|
| merge sans prédicats | 1,8 To (full scan) | 38 min | 14 |
| merge + incremental_predicates (7j) | 12 Go | 4 min | 1,2 |
| delete+insert (7j) | 0 (delete ciblé) | 3 min | 0,8 |
Le passage aux prédicats a divisé le coût par 12. Le delete+insert fait encore mieux car il évite le scan du MERGE.
on_schema_change : gérer les évolutions de colonnes
Quand vous ajoutez une colonne à un modèle incremental, DBT ne la remplit que pour les nouvelles lignes par défaut. Les anciennes lignes ont NULL. La config on_schema_change contrôle ce comportement :
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='event_id',
on_schema_change='sync_all_columns'
) }}
| Valeur | Comportement |
|---|---|
ignore (défaut) |
Les nouvelles colonnes sont ignorées. Les anciennes lignes gardent NULL. |
fail |
DBT plante si le schéma a changé. Force un --full-refresh manuel. |
append_new_columns |
Ajoute les nouvelles colonnes à la table. Les anciennes lignes ont NULL. |
sync_all_columns |
Ajoute les nouvelles colonnes et supprime les colonnes supprimées. |
Mon reco : on_schema_change='sync_all_columns' en production. C'est le seul qui garantit que le schéma de la table correspond toujours au modèle DBT. Les autres laissent des colonnes fantômes ou des NULL silencieux.
Le pattern "full refresh safe" : préserver l'historique
Le problème du --full-refresh : il drop la table et la reconstruit depuis zéro. Sur 2 milliards de lignes, c'est 6 heures. Et si la source ne contient que 90 jours d'historique, vous perdez tout le reste.
Solution : une table temporaire + swap
-- ✅ Pattern full refresh safe
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='event_id',
incremental_predicates=[
"DBT_INTERNAL_DEST.event_date >= DATEADD(day, -30, CURRENT_DATE())"
],
on_schema_change='sync_all_columns',
full_refresh=false -- ⛔ empêche le drop accidentel
) }}
SELECT
event_id,
event_date,
event_type,
payload,
loaded_at
FROM {{ source('raw', 'events') }}
{% if is_incremental() %}
WHERE event_date >= DATEADD(day, -30, CURRENT_DATE())
{% endif %}
Avec full_refresh=false, un dbt run --full-refresh sur ce modèle plantera au lieu de dropper la table. C'est une sécurité contre les accidents. Pour un vrai full refresh, il faut explicitement retirer la config ou utiliser une variable.
Dynamic Tables Snowflake vs Incremental DBT : quand utiliser quoi
C'est la question que tout le monde pose. Vous avez déjà lu mon article sur les Dynamic Tables vs Tasks — voici la version DBT.
| Critère | Incremental DBT | Dynamic Table Snowflake |
|---|---|---|
| Fréquence de refresh | Programmée (Airflow, CRON) | Continue (TARGET_LAG) |
| Logique complexe (multi-joints, fenêtres) | ✅ DBT gère | ⚠️ Limite de 5 niveaux de DT |
| Contrôle fin du SQL | ✅ Total | ⚠️ Déclaratif seulement |
| Latence | Minutes à heures | Secondes à minutes |
| Coût | Crédits par run | Crédits par refresh automatique |
| Gouvernance | DBT (tests, docs, lineage) | Snowflake (grants, tags) |
| Schéma qui change | on_schema_change |
Re-création automatique |
| Historique long (>90j) | ✅ Pas de limite | ⚠️ Data retention limité |
La règle que j'applique :
- Incremental DBT : pipelines batch complexes, multi-jointures, transformations lourdes, historique long
- Dynamic Table : pipelines simples, latence faible (< 1h), peu de jointures, pas d'historique > 90 jours
- Les deux : DBT orchestre, Dynamic Table rafraîchit en continu. DBT crée la Dynamic Table, Snowflake gère le refresh
Les 5 pièges que j'ai vu en production
Piège 1 — Oublier le unique_key sur un merge
-- ⛔ Sans unique_key, merge = insert en doublon
{{ config(materialized='incremental', incremental_strategy='merge') }}
Résultat : 3 mois plus tard, la table fait 4x sa taille normale. Les doublons sont silencieux.
Piège 2 — Filtre source et prédicat de destination désalignés
-- ⛔ La source charge 30 jours, le prédicat n'en filtre que 7
{% if is_incremental() %}
WHERE event_date >= DATEADD(day, -30, CURRENT_DATE())
{% endif %}
-- incremental_predicates ne filtre que 7 jours
Le MERGE charge 30 jours de données mais ne cherche des matchs que sur 7 jours. Les 23 autres jours sont insérés en doublon.
Piège 3 — full_refresh sur une table de 2 milliards de lignes
Un développeur lance dbt run --full-refresh pour tester. La table est droppée. Le pipeline tourne pendant 6 heures. La source ne contient que 90 jours. L'historique de 2 ans est perdu.
Solution : full_refresh=false dans la config.
Piège 4 — on_schema_change='ignore' (le défaut) avec des colonnes calculées
Vous ajoutez une colonne is_premium calculée. Les nouvelles lignes l'ont. Les anciennes ont NULL. Un dashboard PowerBI filtre sur is_premium = true et rate 80% des données.
Solution : on_schema_change='sync_all_columns' + un backfill planifié.
Piège 5 — Runs concurrents sur le même schéma
Deux pipelines lancent le même modèle incremental en parallèle sur le schéma de dev. Les tables temporaires de DBT s'écrasent mutuellement. Résultat : données corrompues silencieusement.
Solution : utiliser snowflake__resolve_incremental_tmp_relation pour router les tables temporaires vers un schéma unique par run (disponible depuis dbt-snowflake 1.10).
Le résultat
Sur le modèle de 2,3 milliards de lignes, après restructuration :
| Métrique | Avant | Après |
|---|---|---|
| Stratégie | merge sans prédicats | delete+insert + prédicats |
| Scan par run | 1,8 To | 12 Go |
| Temps dbt run | 38 min | 3 min |
| Crédits/run | 14 | 0,8 |
| Doublons | Oui (silencieux) | Non (unique_key) |
| Full refresh accidentel | Possible | Bloqué (full_refresh=false) |
| Schéma désynchronisé | Oui | Non (on_schema_change) |
L'incremental n'est pas un mode matériel magique. C'est un contrat entre DBT et Snowflake : DBT s'engage à ne charger que le delta, Snowflake s'engage à ne scanner que ce qui est nécessaire. Si tu ne configures pas les prédicats, tu brises le contrat.