RSS →
ARTICLES · DBT

DBT Incremental sur Snowflake : le guide que j'aurais aimé avoir avant de tout casser

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.

DBT Incremental sur Snowflake — stratégies et pièges

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 :

  1. Le scan de destination : sans incremental_predicates, DBT fait un MERGE qui 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.
  2. Les doublons silencieux : sans unique_key, le MERGE ne sait pas quelles lignes remplacer. Il insère en doublon si la source contient des enregistrements déjà présents.
  3. Le full_refresh qui tue : quand le schéma change (nouvelle colonne), un --full-refresh reconstruit 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.

M

Mikael Paulhiout

Lead Tech / Data Architect. Écrit sur Snowflake, DBT, Airflow et l'IA appliquée.

RSS LinkedIn