En bref. Pour migrer plus de 1 000 modèles dbt de Trino vers BigQuery dans un grand groupe industriel, j’ai livré un CLI Python déterministe qui traite plus de 95 % des modèles sans intervention manuelle. Le chiffre se lit avec son dénominateur : une partie des modèles était portable telle quelle. Les quelque 5 % restants sortent dans un rapport d’écarts, et l’équivalence des données est vérifiée sur les modèles comparés : aucune régression constatée. L’outil n’embarque aucune IA, parce qu’aucune n’est disponible pour les data engineers du client ; voici comment il fonctionne.
Les extraits de code sont génériques : ils illustrent le principe et ne proviennent d’aucun dépôt client. Le contexte complet est sur la fiche migration vers un cloud de confiance.
Pourquoi migrer sans modèle de langage ?
Les data engineers du client n’ont aucune IA générative à disposition, à cause de la classification de l’environnement. Ce n’était donc pas un arbitrage entre deux options : l’outillage devait fonctionner sans modèle de langage, être relu par des data engineers et produire le même résultat à chaque exécution.
L’enjeu est le calendrier, pas le coût : pouvoir basculer rapidement vers la plateforme cible, pour tenir les SLO. L’équipe compte 4 data engineers. Mon estimation, non chronométrée : le travail manuel se comptait en une semaine, et il tient en environ une journée avec l’outil, une grande part du temps restant allant au nettoyage des fichiers _models et _sources.
Un point de transparence : j’ai conçu l’outil avec un assistant de code, ce qui a accéléré l’écriture du CLI. L’outil livré est autre chose : un programme déterministe, testé, qui n’appelle aucun modèle à l’exécution. L’IA a servi à écrire le code, pas à réécrire les requêtes.
Sur un lot de 1 000 modèles, trois propriétés comptent plus que la souplesse : la reproductibilité (même entrée, même sortie), la revue (un diff lisible par fonction réécrite) et l’auditabilité (on sait quelle règle a modifié quelle ligne). Un moteur de règles les garantit. Un LLM peut produire une réécriture correcte, mais pas la même deux fois, et sans trace de la raison.
Comment réécrire du SQL Trino en SQL BigQuery ?
La bibliothèque sqlglot construit un arbre syntaxique à partir du dialecte source et régénère du SQL dans le dialecte cible. Le cœur de l’outil tient en quelques lignes :
import sqlglot
def transpile(sql: str) -> str:
return sqlglot.transpile(sql, read="trino", write="bigquery", pretty=True)[0]Pour beaucoup de fonctions, ce passage suffit. Là où les dialectes divergent, des règles métier prennent le relais. L’ordre d’arbitrage est toujours le même :
- une fonction native de dbt (les macros « cross-database »), quand elle existe ;
- sinon, la transpilation sqlglot ;
- sinon, une règle écrite à la main pour le cas que sqlglot ne sait pas traiter.
Que faire quand les dialectes divergent ?
Commencez par mesurer ce que sqlglot sait déjà faire. J’ai passé 30 fonctions Trino courantes (dates, chaînes, tableaux, JSON, conversions) dans sqlglot 30.12.0, puis validé chaque requête produite par un dry-run BigQuery (--dry_run), le 19 octobre 2026 :
| Résultat | Fonctions | Exemples |
|---|---|---|
| Traduit et accepté par BigQuery | 25 | date_diff('day', a, b) devient DATE_DIFF(b, a, DAY), arguments inversés compris |
| Traduit vers une fonction qui n’existe pas en BigQuery | 4 | year, transform, to_unixtime, day_of_week |
| Laissé tel quel | 1 | format_datetime(ts, 'yyyy-MM') |
Une règle maison pour les 25 premiers serait inutile. Mais « accepté » ne veut pas dire « équivalent » : ces 25 comprennent un cas faux. Les règles servent là où sqlglot échoue, et il échoue de trois façons très différentes.
1. Il laisse la fonction telle quelle. Le nœud reste Anonymous. Ici BigQuery rejette la requête, car le premier argument n’est pas une chaîne. Mais BigQuery possède sa propre FORMAT_DATETIME(format, datetime) : même nom, ordre des arguments inversé, motifs de type strftime. Si l’on se contente d’inverser les arguments, le motif Trino yyyy-MM ne provoque aucune erreur : j’ai obtenu le texte littéral yyyy-MM au lieu de 2026-01. Même nom, autre langage de motif, résultat faux sans erreur. Une règle ne devine pas : elle signale la fonction pour revue.
2. Il traduit vers une fonction inexistante. Quatre cas sur trente. C’est le mode d’échec le moins dangereux : BigQuery répond « Function not found » dès la validation, et un --dry_run systématique dans la CI suffit à l’attraper.
3. Il traduit, mais le sens change. try(cast(s AS integer)) renvoie NULL en Trino si la conversion échoue. sqlglot émet un warning (« Unsupported TRY function ») et produit CAST(s AS INT64). Le dry-run l’accepte, mais l’exécution lève « Bad int64 value: abc », là où SAFE_CAST('abc' AS INT64) renvoie NULL. C’est le trou le plus dangereux : rien ne casse à la compilation. La validation vérifie que la requête est valide, pas qu’elle fait la même chose, ce qui justifie l’audit d’équivalence décrit plus bas.
import sqlglot
from sqlglot import exp
sql = "SELECT try(cast(s AS integer)) AS n, format_datetime(ts, 'yyyy-MM') AS m FROM t"
tree = sqlglot.parse_one(sql, read="trino")
def rule_try_cast(node: exp.Expression) -> exp.Expression:
"""TRY(CAST(x AS t)) rend NULL en cas d'échec : c'est SAFE_CAST en BigQuery."""
if isinstance(node, exp.Try) and isinstance(node.this, exp.Cast):
return exp.TryCast(this=node.this.this, to=node.this.args["to"])
return node
fixed = tree.transform(rule_try_cast)
print(fixed.sql(dialect="bigquery"))
# SELECT SAFE_CAST(s AS INT64) AS n, format_datetime(ts, 'yyyy-MM') AS m FROM t
# Ce qui reste Anonymous n'a pas de règle : à revoir à la main.
print(sorted({n.name for n in fixed.walk() if isinstance(n, exp.Anonymous)}))
# ['format_datetime']Une règle est une fonction pure : elle prend un nœud de l’arbre et renvoie un nœud. Elle est déterministe, aucune IA n’est appelée. Chaque règle est couverte par des tests unitaires, avec des paires entrée/sortie attendues : ajouter un cas, c’est ajouter un test puis une règle. Ces exemples sont génériques, ils ne viennent d’aucun dépôt client.
Un point d’attention, indépendant de l’outil : sqlglot ne comprend pas Jinja. Les modèles dbt contiennent des blocs {{ ref(...) }} qu’il faut masquer avant la transpilation, puis restituer ; l’exemple ci-dessus ne traite que du SQL.
Et les 5 % de modèles que les règles ne couvrent pas ?
Environ 5 % des requêtes relèvent de cas sémantiques complexes : comportement différent d’une fonction aux limites, conversion implicite de types, ordre d’évaluation. L’outil ne tente pas de deviner. Il écrit un rapport d’écarts, en Markdown pour la lecture et en JSON pour l’outillage, qui liste les requêtes à revoir et la raison de chacune.
Exemple synthétique de rapport (aucun rapport réel n’est publié) :
{
"model": "orders_enriched",
"status": "manual_review",
"reason": "cast implicite non équivalent",
"line": 42
}Ces rapports transforment les ~5 % restants en une file de travail priorisée pour l’équipe.
Le « plus de 95 % » demande une lecture honnête : plus de 1 000 modèles, dont une partie était du SQL portable tel quel, sans réécriture. Il mesure la part traitée sans intervention manuelle, pas la part de modèles complexes réécrits.
Comment prouver que les données sont identiques ?
Réécrire un SQL ne prouve pas que les résultats sont identiques. Pour cela, j’utilise dbt_audit_helper, qui compare deux relations ligne à ligne :
{{ audit_helper.compare_relations(
a_relation = ref('orders_trino'),
b_relation = ref('orders_bigquery'),
primary_key = 'order_id'
) }}Attention : compare_relations compare deux relations lisibles depuis le même entrepôt. Pour comparer Trino et BigQuery, il faut d’abord rendre l’une des deux accessible à l’autre. Le protocole de comparaison est propre à l’environnement du client et n’est pas publié ici.
Résultat sur les modèles comparés : aucune régression constatée. « Strictement identiques » veut dire : mêmes colonnes, même nombre de lignes, mêmes valeurs colonne par colonne. Cette validation a été menée dans l’environnement du client : ses résultats détaillés ne sont pas publiables.
Qu’est-ce que le déterminisme apporte, et où s’arrête-t-il ?
Ce que j’en retire :
- des diffs reproductibles, relus par les data engineers du client ;
- une règle identifiable derrière chaque modification ;
- un outil installable dans un environnement sans accès à l’IA générative ;
- une automatisation de plus de 95 % des modèles.
Les limites sont à reconnaître. Les quelque 5 % de cas sémantiques restent manuels, et un moteur de règles ne couvre que ce que ses règles décrivent. La migration reste un travail d’équipe : refonte des macros dbt, conventions de nommage, orchestration Dagster, intégration CI/CD avec l’équipe DevOps. Prévu : former les équipes à l’usage du CLI, accompagner la passation vers BigQuery et aider au debug.
À retenir
Le déterminisme n’est pas opposé à l’IA : il définit où elle a sa place. Ici, elle a aidé à écrire l’outil. Ce qui tourne en production est un programme que l’on peut tester, rejouer et auditer.
Une migration à fiabiliser ? Le même groupe a aussi reçu un package d’observabilité dbt et des contrats de données. Pour échanger, écrivez-moi.
Cette mission a été réalisée dans le cadre de Thélio, société de conseil et services data. Pour échanger sur un sujet proche, écrivez-moi.