En bref
En clair : Un prix de 1,20 € est stocké comme un grand nombre entier accompagné d’une règle de conversion. Lu sans cette règle, le navigateur affichait le grand nombre à la place du prix.
Un DECIMAL exporté en Parquet n’arrive pas comme un nombre à virgule dans un navigateur : DuckDB WASM transmet l’entier brut (la mantisse). Chez moi, des prix de carburant s’affichaient à 1 200 000 000 € le litre au lieu de 1,200 €. Le défaut de fond était l’absence de contrat de type à la frontière d’export, plus qu’une colonne mal castée. Il m’a fallu trois itérations, dont une erreur de raisonnement sur une colonne que je croyais épargnée. Règle retenue : typer chaque colonne exportée pour son lecteur, ici un type flottant.
Quel est le contexte ?
Mon projet personnel collecte les prix des carburants des stations-service françaises (données publiques). dbt produit des tables agrégées, exportées en Parquet. Le site web les interroge avec DuckDB WASM, directement dans le navigateur, sans serveur de requêtes. Le même projet dbt tourne sur DuckDB et sur BigQuery.
Deux modèles du mart « enrichi » concernent ce cas : l’agrégat quotidien des prix et l’agrégat par type d’enseigne. Tous deux ont un contrat dbt activé : le type déclaré dans le fichier yml doit être exactement celui que produit le SQL, sinon le build échoue.
Quel était le problème ?
Dans le navigateur, min_fuel_price et max_fuel_price affichaient 1200000000,000 €/L au lieu de 1,200 €/L. avg_fuel_price, dans la même table, était correct. Un minimum absurde à côté d’une moyenne juste désoriente : le calcul n’était pas en cause, seule la valeur lue changeait en route.
Qu’ai-je fait ?
Première itération : trouver la cause. Les trois colonnes min, max et median étaient castées en NUMERIC, ce qui donne DECIMAL(38,9) dans DuckDB. Un DECIMAL(p, s) est stocké comme un entier, la mantisse : 1,2 avec s = 9 devient 1200000000. Quand DuckDB WASM transmet la colonne via Apache Arrow, JavaScript reçoit un BigInt, l’entier brut, sans la division par 10^9. Je pensais avg_fuel_price épargné : en SQL, avg() renvoie nativement un DOUBLE, alors que min(), max() et median() gardent le type de leur entrée. J’ai remplacé le cast par un type flottant sur les trois colonnes. Comme le motif pouvait se cacher ailleurs, j’ai cherché translate_type('numeric') dans tout le dossier des modèles : il y avait un second modèle concerné, pas d’autre. Les deux contrats ont suivi : 'DOUBLE' if target.type == 'duckdb' else 'float64' dans leur yml.
Deuxième itération : un type qui n’existe pas partout. J’avais écrit le cast avec api.Column.translate_type('double'). Sur DuckDB, tout passait. Mon premier vrai run sur BigQuery a échoué dans les deux modèles :
Database Error in model agg_daily_fuel
Type not found: double at [27:14]translate_type() cherche la clé demandée dans le dictionnaire TYPE_LABELS de l’adapter et, si elle n’y est pas, renvoie l’argument tel quel. L’adapter BigQuery ne traduit que FLOAT en FLOAT64 : "double" partait donc littéralement dans le SQL, alors que BigQuery nomme ce type FLOAT64. DuckDB accepte DOUBLE, d’où une erreur visible sur un seul moteur. J’ai choisi explicitement le type par adapter, comme le faisait déjà le yml :
('DOUBLE' if target.type == 'duckdb' else 'FLOAT64')Troisième itération : la colonne que je croyais épargnée. Le même run affichait un warning que je n’avais pas lu au premier passage : des colonnes de type numeric sans precision ni scale spécifiés, avg_fuel_price en tête. Mon raisonnement initial était faux. Il supposait que la colonne n’était pas recastée après l’agrégation, or le SQL appliquait un safe_cast vers numeric sur le résultat de round(avg(fuel_price), 3). C’est ce cast qui décide du type final, pas la fonction d’origine. La colonne avait donc le même défaut, simplement pas encore repéré côté frontend. Je l’ai passée au même type flottant, dans les deux modèles et leurs yml. Les quatre colonnes de prix sont maintenant cohérentes.
Qu’ai-je appris, et à quoi cela renvoie-t-il ?
Quelles règles en retenir ?
- Pour des colonnes lues par un frontend JavaScript via Arrow ou Parquet, préférer
DOUBLEouFLOAT64àDECIMAL, tant qu’aucun besoin métier n’exige une précision exacte. - Vérifier le type final colonne par colonne, pas la fonction d’agrégation : un cast appliqué après l’agrégat décide du type.
- Ne se fier à
translate_typeque pour les clés que l’adapter cible traduit, et tester sur chaque moteur réellement utilisé. - Chercher le motif dans tout le projet avant de corriger, puis chercher le suivant : un seul grep a trouvé le second modèle.
- Lire les warnings d’un run réel. Celui sur la précision non spécifiée décrivait déjà la troisième itération.
Quels principes connus ce cas illustre-t-il ?
Un décimal à virgule fixe est une paire (entier, scale), pas un nombre à virgule. Quiconque le lit sans le schéma logique de la colonne obtient l’entier brut : c’est le piège classique d’une donnée sérialisée sans son décodeur.
Ce cas rejoint aussi un principe que j’ai vu dans mon REX sur les macros cross-database : la portabilité d’un projet ne se limite pas à une syntaxe qui compile, elle se prouve en exécutant sur chaque cible.
Que ferais-je autrement ?
- Garder le
DECIMALet le décoder côté frontend, en lisant le scale dans le schéma Arrow pour diviser par10^scale. Cela préserve la précision exacte, ce que des contrôles réglementaires sur des prix pourraient exiger un jour. Cela coûte du code JavaScript à maintenir et un risque d’oubli à chaque nouvelle colonne. Je ne l’avais pas retenu : un prix au millième d’euro n’a pas besoin d’une précision arbitraire. - Ajouter un test automatique sur le fichier exporté : lire le schéma du Parquet et échouer si une colonne de prix est de type
DECIMAL. Cela attrape le défaut avant le navigateur, pour le prix d’un petit test à écrire et à tenir à jour. Je ne l’avais pas fait parce que je n’avais vu le problème qu’à l’écran. - Compiler le projet sur les deux moteurs dès la première modification, au lieu de tester sur DuckDB seul. La deuxième itération aurait disparu, au prix d’une cible de plus à maintenir à chaque changement. Je développais en local sur DuckDB et je ne lançais BigQuery qu’ensuite.