En bref

En clair : Mon projet relisait plusieurs fois les mêmes données sans que je le voie, et la facture le payait. En cherchant au bon endroit, le volume lu a été divisé par près de trois en deux mois.

En juillet 2026, BigQuery a lu 1,353 TiB de données pour mon projet, qui reste modeste (facture de 9,93 € en juillet). Mon suspect évident, le modèle qui traite chaque jour les nouvelles données, n’y était presque pour rien. La vraie cause : une « vue » sur un fichier d’archive de 422 Mo était recalculée en entier par chacun de ses tests de qualité. Les jobs qui ne lisaient que cette archive représentaient 73 % du volume. Après correction, le volume mensuel est passé à 0,907 TiB en août, puis 0,470 TiB en septembre.

Quel est le contexte ?

Mon projet personnel collecte chaque jour les prix des carburants en France (données publiques). Les données arrivent en fichiers Parquet sur GCS. BigQuery les lit directement via des tables externes (pas de copie : le fichier est relu à chaque requête), et dbt les transforme en trois couches (staging, intermediate, marts), puis lance ses tests. Le tout tourne dans un job Cloud Run (conteneur lancé à heure fixe). Ce retour d’expérience décrit la version BigQuery du projet ; sa migration vers Scaleway est décrite dans la fiche Plein Data.

BigQuery facture au volume de données lues par requête.

Quel était le problème ?

Le volume lu était élevé et en hausse, sans incident visible : le traitement et les tests étaient au vert. Ni la taille des tables ni la configuration ne désignaient de responsable. Je ne savais pas quelle requête consommait.

Qu’ai-je fait ?

1. Vérifier le suspect évident avant d’annoncer un gain. Le modèle incrémental filtrait sur une colonne calculée : BigQuery ne pouvait donc pas ne lire que les fichiers du bon jour (le partition pruning). Je l’ai corrigé, puis j’ai regardé combien il lisait vraiment. Son MERGE traitait 2,1 Mio. Vérifié dans l’historique des requêtes : les MERGE de juillet traitent entre 1,98 et 2,31 Mio. Ce chiffre ne couvre pas la lecture de la source, que dbt fait dans une requête séparée (la création d’une table temporaire). Il suffisait pour douter de ma thèse, et je l’ai abandonnée.

2. Lire les statistiques réelles d’une requête. J’ai lancé un test à la main et lu les statistiques de la requête exécutée, pas une estimation (commande bq show -j). Un seul test not_null traitait 2,8 Go. Le modèle testé était une vue sur l’archive historique, un fichier de 422 Mo non partitionné. Une vue est rejouée à chaque lecture : chaque test relançait donc la lecture complète du fichier. C’est le comportement documenté (documentation BigQuery) : la requête de la vue est exécutée à chaque lecture, et facturée.

3. Corriger : matérialiser en table (stocker le résultat). J’ai transformé la vue en table : une seule lecture du fichier, puis les tests interrogent une table native. J’ai écarté l’idée de réduire le nombre de tests, qui aurait baissé la couverture au lieu de supprimer la redondance.

4. Mesurer par une expérience contrôlée, puis recouper avec l’historique. J’ai remis le modèle en vue, lancé ses 11 tests (les 11 déclarés à cette période d’après la table de suivi des exécutions, dbt_results : 11 par jour du 27 juillet au 1ᵉʳ août, puis 3, puis 4), sommé les octets réellement facturés, puis recommencé avec une table.

ConfigurationOctets facturés (11 tests)
vue19,9 Go
table (construction + 11 tests)environ 14,6 Go

Gain mesuré : environ 26 % sur ce périmètre. Le coût par test varie de 55,6 Mo à 1,9 Go selon la colonne : multiplier 2,8 Go par le nombre de tests aurait faussé le calcul.

Cette expérience ne couvre que 11 tests. L’historique des jobs BigQuery, conservé 180 jours, permet de mesurer le poste entier. Il ne liste que les tables sous-jacentes, jamais une vue : on filtre donc sur le fichier lu. En ne gardant que les jobs qui lisent une seule table, il n’y a aucun double compte :

select ref.dataset_id, ref.table_id, count(*) as jobs,
       round(sum(total_bytes_billed) / pow(1024, 4), 3) as tib_facturés
from `region-eu`.INFORMATION_SCHEMA.JOBS_BY_PROJECT,
     unnest(referenced_tables) as ref
where creation_time >= timestamp('2026-07-01')
  and creation_time <  timestamp('2026-08-01')
  and total_bytes_billed > 0
  and array_length(referenced_tables) = 1
group by 1, 2
order by tib_facturés desc
limit 8;

Pour juillet : 381 jobs ne lisant que l’archive historique ont facturé 0,986 TiB, soit 73 % des 1,353 TiB du mois. Le modèle incrémental, mon suspect, en pèse 0,139 TiB (10 %). Les jobs qui lisent plusieurs tables ajoutent 0,134 TiB. Cette attribution recoupe le total à environ 1 % près.

5. Instrumenter, puis subir une régression. J’ai enregistré les octets lus par chaque test dans la table de suivi : « quel test coûte cher ? » est devenu une simple requête. Un audit a retiré 51 tests sur 142 qui ne pouvaient structurellement pas échouer. Plus tard, un changement a remis l’archive en vue, pour éviter de reconstruire 55 millions de lignes à chaque exécution locale sur DuckDB. Cela a annulé ma correction, et seul l’export de facturation l’a révélé. Revenir à une table ne convenait pas : la reconstruction quotidienne lisait 6,2 GiB, plus que les tests restants (5,8 GiB). J’ai donc retiré les tests restants les plus coûteux sur cette archive figée : un risque assumé, écrit dans la description des colonnes. Le budget de tests est passé d’environ 6 GiB à environ 0,35 GiB par jour normal. Remesuré en octobre sur 7 jours : 3,19 GiB facturés pour les tests (environ 0,46 GiB par jour) et 1,89 GiB pour les modèles.

MoisVolume facturé (tous jobs)
juillet 20261,353 TiB
août0,907 TiB
septembre0,470 TiB
octobre (au 17)0,062 TiB

Ces baisses suivent la période où j’ai retiré des tests et corrigé la matérialisation ; je n’ai pas isolé l’effet de chaque changement. Les mesures pointent pourtant vers les tests retirés plutôt que vers la table : l’expérience contrôlée ne montre que 26 % de gain, et la table reconstruite chaque jour coûte plus que les tests qu’il reste. Un point reste inexpliqué : 2,8 Go traités pour un fichier de 422 Mo (hypothèse non vérifiée : la taille décompressée du Parquet).

6. Une hypothèse que la facture a infirmée. Je soupçonnais aussi la région : le bucket était dans une seule région, les tables BigQuery en multi-région EU. J’ai déplacé le bucket vers EU le 1ᵉʳ août. L’export de facturation par SKU (ligne de tarif) montre bien le changement ce jour-là, d’« Inter Region » à « Multi-region ». Mais le prix par GiB transféré reste le même (environ 0,0175 €, puis 0,0176 €) et le volume hebdomadaire oscille toujours entre 22 et 59 GiB en août. La variable qui comptait était le nombre d’octets lus, pas l’emplacement.

L’échelle en euros, pour garder les ordres de grandeur. Facture du projet avant crédits : juillet 9,93 €, août 7,93 €, septembre 4,76 €, octobre (au 16) 1,23 €. La part de l’analyse BigQuery y est faible : 1,94 € en juillet, 0 € en août et en septembre, car, d’après mon export de facturation, rien n’est facturé sous 1 TiB lu par mois. Le transfert réseau de Cloud Storage en Europe pèse 3,68 €, 3,30 €, 1,76 € puis 0,30 €. Le mécanisme se transpose à un gros projet ; le montant, non.

Qu’ai-je appris, et à quoi cela renvoie-t-il ?

Quelles règles en retenir ?

  • Le coût d’une vue croît avec le nombre de ses références. Chaque test et chaque modèle en aval est une exécution complète.
  • La matérialisation est un levier de coût, avec une limite. Une table reconstruite chaque jour peut coûter plus que les tests qu’elle protège.
  • « Ne peut jamais échouer » et « peut échouer, je choisis de ne pas le surveiller » sont deux décisions distinctes. Elles se documentent séparément.
  • Les statistiques de la requête priment sur le modèle mental. Deux de mes hypothèses ont été contredites par elles.
  • Lire la facture par SKU, et mesurer l’effet d’un correctif sur la facture, pas sur sa logique. Le déplacement du bucket était cohérent sur le papier ; seul l’avant/après par SKU a montré qu’il n’avait rien changé.

Quels principes connus ce cas illustre-t-il ?

  • Le problème des requêtes « N+1 » des ORM. Une opération anodine, ici un test, déclenche autant d’exécutions complètes qu’il y a de références. Une vue n’est pas un cache : c’est une requête mémorisée, rejouée à chaque lecture.
  • La règle de l’optimisation mesurée. J’avais optimisé le modèle que je voyais, pas le poste que la mesure désignait. Quand le goulot n’est pas là où l’on regarde, le profilage passe avant l’intuition.

Que ferais-je autrement ?

  • Charger l’archive de 422 Mo dans une table native, hors dbt. Plus de vue sur GCS, plus de relecture par test. Coût : une étape de chargement à rejouer à chaque mise à jour mensuelle.
  • Valider l’archive une seule fois à l’ingestion. Coût : une seconde logique de qualité à maintenir hors dbt.
  • Garder les tests retirés dans un job hebdomadaire. Coût : un job de plus à surveiller.