Partitions, shuffle, RDD contre DataFrame, cache, joins et UDF : les questions posées en entretien de data engineering, avec la réponse qu’attend l’intervieweur.
Cliquez sur une question pour dérouler la réponse attendue.
Un RDD (Resilient Distributed Dataset) est une collection d’objets répartie en partitions sur plusieurs machines, immuable, et reconstructible en cas de panne.
Les trois mots du sigle portent la réponse complète :
Ce qu’il faut ajouter pour montrer qu’on a compris la limite : un RDD n’a pas de schéma, donc Catalyst ne peut pas l’optimiser. C’est pourquoi on écrit du DataFrame en pratique, et du RDD seulement pour du contrôle fin de partition ou des données sans structure tabulaire.
Une transformation décrit un calcul sans l’exécuter ; une action déclenche l’exécution. C’est ce qui permet à Spark de voir toute la chaîne avant de la lancer, donc de l’optimiser.
Lire la réponse détailléesc est le SparkContext : le point d’entrée de l’API RDD (parallelize, textFile, variables diffusées, accumulateurs, dossier de checkpoint).
spark est la SparkSession : le point d’entrée unifié depuis Spark 2.0, qui couvre les DataFrames et le SQL (read, sql, createDataFrame, conf, catalog).
La relation compte autant que la définition : la session contient le contexte, récupérable par spark.sparkContext. Les deux sont créés automatiquement par un spark-shell, mais dans une application soumise avec spark-submit, c’est à vous de construire la session.
val spark = SparkSession.builder().appName("job").getOrCreate()
val sc = spark.sparkContextDeux points qui font bonne impression : il n’existe qu’un seul SparkContext par JVM — d’où getOrCreate() plutôt que new — et si un tutoriel utilise sqlContext ou HiveContext, il précède Spark 2.0, où ces deux objets ont été fusionnés dans la session.
Les trois sont immuables, distribués et tolérants aux pannes. Ce qui les sépare tient en deux axes : le schéma et le typage.
| RDD | DataFrame | Dataset[T] | |
|---|---|---|---|
| Schéma de colonnes | non | oui | oui |
| Types vérifiés à la compilation | oui | non | oui |
| Optimiseur Catalyst | non | oui | oui |
| Disponible en Python | oui | oui | non |
La phrase qui résume : le RDD est une collection distribuée bas niveau — vous décrivez comment traiter ; le DataFrame est une table distribuée haut niveau — vous décrivez ce que vous voulez, et Spark choisit comment.
C’est précisément parce qu’on cède le « comment » que Catalyst peut réécrire le plan, ce qui rend le DataFrame nettement plus rapide dans la quasi-totalité des cas.
Deux précisions qui montrent la maîtrise : DataFrame est Dataset[Row], donc passer de l’un à l’autre par .as[T] ne coûte rien ; et Dataset[T] n’existe pas en PySpark, puisque le typage à la compilation suppose un compilateur.
Une partition est un morceau des données traité par une seule tâche, sur un seul cœur, de façon séquentielle. C’est l’unité de parallélisme de Spark : le nombre de partitions détermine combien de tâches peuvent tourner en même temps.
La formulation qui montre qu’on a compris : une partition est l’unité de travail, une tâche est son exécution. Un RDD de 200 partitions produit 200 tâches par étape.
Deux réglages à citer, parce que ce sont ceux qu’on ajuste en pratique :
sc.defaultParallelism fixe le nombre de partitions initiales, dérivé du nombre de cœurs disponibles.spark.sql.shuffle.partitions (200 par défaut) fixe le nombre de partitions après un shuffle — c’est presque toujours cette valeur qu’il faut changer.La règle de dimensionnement usuelle : viser 100 à 200 Mo de données par partition, et un nombre de partitions égal à deux ou trois fois le nombre de cœurs. Trop peu de partitions laisse des cœurs inactifs ; trop de partitions noie le job dans le coût d’ordonnancement des tâches.
La redistribution des données entre partitions, déclenchée par un groupBy, un join ou un repartition. Coûteuse parce qu’elle cumule écriture disque, transfert réseau et sérialisation.
Lire la réponse détailléeParce que reduceByKey agrège avant le shuffle et ne transfère que des résultats partiels, là où groupByKey déplace chaque valeur. Trois ordres de grandeur sur le volume réseau.
Lire la réponse détailléeLes deux changent le nombre de partitions ; une seule fait un shuffle.
repartition(n) shuffle et équilibre les partitions. Il peut augmenter ou réduire le nombre de partitions, et il coûte un transfert réseau complet.
coalesce(n) fusionne des partitions voisines sans shuffle — c’est quasi gratuit. Deux contreparties : il ne peut que réduire (demander plus de partitions qu’il n’en existe est ignoré en silence), et il hérite du déséquilibre de départ.
La réponse qui distingue un candidat qui a pratiqué : le bon usage de coalesce est juste avant l’écriture, pour éviter de cracher 200 petits fichiers.
resultat.coalesce(10).write.parquet("s3a://bucket/sortie/")Le piège classique, à mentionner : coalesce placé en amont d’une grosse agrégation laisse les tailles inégales, et une seule tâche supporte tout le poids. Pire, comme coalesce ne shuffle pas, il remonte dans le plan et réduit aussi le parallélisme des étapes précédentes. Si vous avez besoin de moins de partitions mais de tâches équilibrées, c’est repartition qu’il faut, malgré son coût.
Cela dépend de l’origine des données, et c’est le piège de la question : il y a deux réglages différents.
À la création, sc.defaultParallelism décide. En mode local, il vaut le nombre de cœurs de la machine ; sur un cluster, le total des cœurs des exécuteurs, avec un plancher de 2.
À la lecture d’un fichier, c’est la taille des blocs qui décide : Spark crée environ une partition par bloc de 128 Mo (spark.sql.files.maxPartitionBytes). Un fichier Parquet de 1 Go donne donc environ 8 partitions.
Après un shuffle, c’est spark.sql.shuffle.partitions, qui vaut 200 par défaut — indépendamment du volume, de la taille du cluster et du bon sens. C’est la valeur qu’on ajuste le plus souvent.
sc.defaultParallelism // parallélisme de base
spark.conf.get("spark.sql.shuffle.partitions") // "200"
df.rdd.getNumPartitions // le compte réelLe détail à ajouter pour montrer l’expérience : spark.default.parallelism doit être posé avant la création du contexte pour être pris en compte, alors que spark.sql.shuffle.partitions se règle à chaud, requête par requête.
Non. Une partition est une tâche potentielle, pas un cœur réservé.
Avec 100 partitions et 10 cœurs disponibles, Spark exécute 10 tâches à la fois et enchaîne 10 vagues successives. Rien n’échoue, rien n’attend anormalement : l’ordonnanceur distribue les tâches aux cœurs libres au fur et à mesure.
C’est pour cela qu’avoir plus de partitions que de cœurs est recommandé, contrairement à l’intuition. Deux à trois fois le nombre de cœurs est la règle usuelle, pour deux raisons :
Les deux cas à éviter, qu’il faut nommer :
Le détail de l’exécution par vagues est dans pourquoi 100 partitions ne font pas 100 processeurs.
cache est un persist au niveau par défaut, persist laisse choisir le stockage, et checkpoint écrit sur un stockage fiable en coupant la lignée. Seul le dernier règle les traitements itératifs.
Lire la réponse détailléeParce qu’une UDF est une boîte noire pour Catalyst. L’optimiseur ne peut pas lire ce qu’elle fait, donc il perd trois leviers d’un coup :
Le troisième point est le plus coûteux dans les faits : un filtre qui aurait éliminé 99 % des données au niveau du fichier doit maintenant charger 100 % des lignes pour les évaluer une à une.
En PySpark, un facteur s’ajoute : chaque ligne traverse la frontière JVM ↔ Python. C’est là que l’écart avec Scala devient réel, et qu’il faut mentionner les UDF vectorisées (pandas_udf), qui transfèrent par lots via Arrow et réduisent fortement le surcoût sans le supprimer.
La conclusion à donner : avant d’écrire une UDF, chercher dans org.apache.spark.sql.functions — when, regexp_extract, coalesce, split, date_format couvrent l’essentiel des cas où l’on est tenté d’en écrire une.
Sur Databricks, la facture est double : une UDF fait aussi retomber l’étape sur Spark JVM au lieu du moteur Photon, que l’on paie pourtant.
Catalyst est l’optimiseur de requêtes de Spark SQL. Il transforme ce que vous écrivez en un plan d’exécution efficace, en quatre étapes :
select("agge") échoue.Les deux optimisations à nommer, parce que ce sont celles qui changent tout en pratique : le predicate pushdown (le filtre descend jusqu’au fichier Parquet, qui saute des blocs entiers) et le column pruning (seules les colonnes demandées sont lues).
Le point clé de la réponse : Catalyst ne fonctionne que sur des expressions qu’il peut lire. Un RDD ou une UDF est opaque pour lui, ce qui explique d’un coup pourquoi le DataFrame est plus rapide que le RDD et pourquoi une UDF annule le gain.
Il copie la petite table sur chaque exécuteur pour joindre localement, sans shuffle de la grande. La stratégie la plus rapide quand la table tient en mémoire, avec un risque d’OOM sur le driver.
Lire la réponse détailléeUne ou quelques clés portent l’essentiel des lignes, donc une seule tâche traite presque tout. On mesure dans la Spark UI, puis on choisit entre AQE, isolation des sentinelles, broadcast ou salting.
Lire la réponse détailléeÇa dépend de ce que vous écrivez — et répondre « oui » sans nuance est une erreur fréquente.
Sur l’API DataFrame, les performances sont équivalentes. Votre code Python ne fait que construire un plan logique, qui est envoyé à la JVM et exécuté par Catalyst. Le langage d’écriture disparaît avant l’exécution : deux jobs identiques en Scala et en PySpark produisent le même plan physique.
Scala gagne réellement dans trois cas :
pandas_udf) qui transfèrent par lots via Arrow.Dataset[T] typé, qui n’existe pas en Python faute de compilateur.La conclusion à donner, parce que c’est celle qui compte en équipe : le choix se fait sur l’écosystème et les compétences, pas sur la vitesse. PySpark donne accès à pandas, scikit-learn et aux bibliothèques de science des données ; Scala donne le typage à la compilation, un seul artefact JVM et un accès direct aux API internes. Écrivez du DataFrame dans les deux cas et la question de performance ne se pose plus.
Quatre raisons, et la première est la plus importante : Parquet est orienté colonnes, le CSV est orienté lignes.
WHERE date > '2026-01-01' peut sauter des blocs entiers sans les décompresser — c’est le predicate pushdown.La formule à retenir et à énoncer : le CSV est un format d’échange, pas un format de stockage. On le convertit une fois à l’arrivée, avec un schéma explicite, puis on ne relit plus que du Parquet ou du Delta.
Le cas où le CSV reste justifié, à mentionner pour ne pas paraître dogmatique : un échange avec un système qui ne lit rien d’autre, ou un fichier destiné à être ouvert à la main.
Deux coûts, et le second est celui qui compte.
Le coût visible : Spark effectue une passe complète supplémentaire sur les données, uniquement pour déterminer les types. Sur 500 Go, vous payez deux balayages là où un seul était nécessaire — temps de lecture doublé et deux fois les requêtes GET facturées par le stockage objet.
Le coût invisible : un schéma deviné dépend des données du jour. Une colonne d’entiers est typée Integer aujourd’hui ; demain, un système en amont écrit un seul N/A, et elle devient String. Votre code ne change pas, mais $"age" > 18 passe en comparaison lexicographique — et "9" > "18" renvoie true. Le job réussit et produit des chiffres faux.
La bonne pratique à énoncer :
spark.read
.option("header", "true")
.schema("nom STRING, age INT, salaire DOUBLE")
.csv("s3a://bucket/clients.csv")Le schéma déclaré supprime la passe et fait échouer immédiatement toute donnée non conforme, ce qui est le comportement souhaitable.
Le piège à éviter en réponse : ne pas dire « je vais simplement enlever l’option ». Sans inferSchema et sans schéma, tout est lu en String, donc le bug de comparaison devient permanent.
Et la remarque qui clôt le sujet : tout ce débat ne concerne que le CSV. Parquet porte son schéma dans son pied de fichier, donc il n’y a rien à deviner.
C’est l’accumulation de milliers de fichiers de quelques kilo-octets dans un lac de données, alors que le stockage et le moteur sont dimensionnés pour des fichiers de 100 Mo à 1 Go.
Le coût se paie à trois endroits :
La cause la plus fréquente : un job qui écrit avec 200 partitions par défaut, exécuté toutes les heures. Cela fait 4 800 fichiers par jour, dont l’essentiel est minuscule.
Les corrections à citer :
// À l'écriture : contrôler le nombre de fichiers produits
resultat.coalesce(10).write.mode("append").parquet(chemin)
// Ou, mieux sur du partitionné, viser une taille de fichier
resultat.repartition($"date").write.partitionBy("date").parquet(chemin)Et surtout : ne pas partitionner sur une colonne à forte cardinalité. partitionBy("user_id") sur un million d’utilisateurs crée un million de dossiers — c’est la façon la plus rapide de créer le problème.
Le point qui montre l’expérience : Delta Lake règle cela avec OPTIMIZE, qui compacte les petits fichiers en arrière-plan, et le compactage automatique sur Databricks.
Une vue temporaire est un nom enregistré dans le catalogue de la session, associé au plan logique d’un DataFrame. C’est ce qui permet de l’interroger en SQL.
df.createOrReplaceTempView("personnes")
spark.sql("SELECT * FROM personnes WHERE id = 1").show()La réponse qui compte, et que beaucoup de candidats manquent : elle ne contient aucune donnée. Ce n’est pas un cache, c’est un alias. Chaque requête sur la vue réexécute le plan depuis le début, donc deux requêtes sur une vue posée sur 400 Go relisent 400 Go deux fois. Pour matérialiser, il faut cache() sur le DataFrame sous-jacent.
Les portées, si on demande la différence entre les variantes :
| Méthode | Visible depuis | Disparaît |
|---|---|---|
createOrReplaceTempView | la session courante | fin de session |
createGlobalTempView | toutes les sessions de l’application | fin de l’application |
saveAsTable | le metastore | jamais — les données sont écrites |
Le piège à mentionner : une vue globale vit dans une base système, donc il faut la préfixer — SELECT * FROM global_temp.personnes. Sans le préfixe, l’erreur est Table or view not found.
L’AQE, introduite en Spark 3 et activée par défaut depuis la 3.2, permet à Spark de réviser son plan pendant l’exécution, à partir des statistiques réelles mesurées à chaque fin d’étape.
C’est la réponse à une limite structurelle de Catalyst : il planifie avant d’avoir vu les données, donc à partir d’estimations qui peuvent être largement fausses — surtout sur des tables sans statistiques, ou après plusieurs transformations.
Trois optimisations à nommer :
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")La nuance qui montre la maturité : l’AQE ne dispense pas de comprendre les partitions. Elle corrige après coup ce qu’elle observe, mais elle ne peut agir qu’aux frontières de shuffle — elle ne rattrapera pas un partitionnement d’entrée catastrophique, ni un partitionBy sur une colonne à cardinalité démesurée.
Avec explain(), et en le lisant de bas en haut : les feuilles de l’arbre sont les sources de données, la racine est le résultat final.
df.explain(true) // les quatre plans : parsé, analysé, optimisé, physique
df.explain("formatted") // le plan physique, lisible, avec les détails numérotésLes cinq éléments qu’on cherche en priorité :
PushedFilters sur le nœud de lecture. S’il est vide alors que votre requête filtre, le filtre s’applique après lecture de tout le fichier — le signe d’une UDF ou d’une expression que Catalyst ne peut pas traduire.ReadSchema. Il doit ne contenir que les colonnes utiles. S’il contient tout, le column pruning n’a pas eu lieu.BroadcastHashJoin, SortMergeJoin, ShuffleHashJoin, ou l’alarmant BroadcastNestedLoopJoin, qui signale une jointure sans condition d’égalité et une complexité quadratique.Exchange. Chaque occurrence est un shuffle. Les compter donne le nombre d’étapes, et c’est le premier levier d’optimisation.WholeStageCodegen. Les nœuds regroupés sous un même identifiant sont compilés en une seule boucle. Une étape qui en sort — typiquement à cause d’une UDF — perd cette fusion.Le complément à mentionner, parce qu’il distingue la théorie de la pratique : le plan dit ce que Spark prévoit, la Spark UI dit ce qui s’est passé. C’est là qu’on voit la durée réelle par étape, la distribution des durées de tâches, le volume de shuffle et les déversements sur disque. Avec l’AQE, le plan effectivement exécuté peut d’ailleurs différer du plan initial — l’onglet SQL de l’UI montre le plan final.
Le driver est le processus qui exécute votre main. Il construit le plan, le découpe en étapes et en tâches, demande des ressources au gestionnaire de cluster, distribue les tâches et collecte les résultats. C’est lui qui détient le SparkContext.
Les executors sont les processus JVM lancés sur les nœuds du cluster. Ils exécutent les tâches, stockent les données mises en cache, et servent les fichiers de shuffle aux autres executors.
Entre les deux, le gestionnaire de cluster (YARN, Kubernetes, Mesos ou le mode standalone) attribue les ressources. Il n’exécute aucun calcul applicatif.
Les deux conséquences pratiques qui font une bonne réponse :
Le driver est un point de défaillance unique. S’il tombe, le job entier meurt. C’est aussi lui qui souffre de collect() sur un gros DataFrame — les données remontent dans sa mémoire — et du broadcast, qui passe par lui avant redistribution.
Le code exécuté dans une transformation tourne sur les executors. D’où l’erreur classique : un println dans un map n’apparaît pas dans vos journaux, il part dans ceux de l’executor. Et toute variable capturée par une closure doit être sérialisable, sinon Task not serializable — c’est précisément ce que résolvent les variables diffusées et les accumulateurs.
Trois niveaux, du plus grand au plus petit :
count() produisent deux jobs.La règle à énoncer, parce qu’elle prouve la compréhension : le nombre d’étapes d’un job est le nombre de shuffles plus un. Et le nombre de tâches d’une étape est le nombre de partitions qu’elle traite.
action → 1 job
├── stage 0 : lecture + filter + map (200 tâches)
│ ⇅ shuffle
└── stage 1 : agrégation + écriture (200 tâches)Le point important sur l’enchaînement : les étapes sont séquentielles quand elles dépendent l’une de l’autre, à cause de la barrière du shuffle — l’étape 1 ne démarre pas avant que la dernière tâche de l’étape 0 soit terminée. C’est pour cela qu’une seule tâche lente retient tout le job, et c’est le mécanisme derrière le problème de skew.
Deux étapes indépendantes, en revanche — deux branches d’un join, par exemple — peuvent s’exécuter en parallèle si les ressources le permettent.
D’abord identifier le processus tombé : driver ou exécuteur, les causes n’ont rien en commun. Puis mesurer avant de rallonger la mémoire, qui est le dernier levier et pas le premier.
Lire la réponse détailléeTungsten est le moteur d’exécution de Spark SQL. Là où Catalyst optimise quoi exécuter, Tungsten optimise comment, sur trois axes :
Le codegen prend une étape entière — lecture, filtre, projection, agrégation partielle — et génère à la volée une seule fonction Java qui fait tout dans une seule boucle, compilée par le JIT.
Sans codegen, chaque opérateur est un objet qui appelle le suivant par une méthode virtuelle, ligne par ligne : c’est le modèle volcano, où le coût des appels dépasse souvent le calcul utile. Avec codegen, ces appels disparaissent, remplacés par du code aussi direct qu’une boucle écrite à la main.
C’est ce qu’on voit dans le plan physique sous WholeStageCodegen, avec un identifiant partagé par les nœuds fusionnés.
La conséquence à énoncer, qui relie tout : dès qu’une UDF ou une opération RDD s’intercale, l’étape sort du codegen et retombe sur le modèle ligne par ligne, avec désérialisation vers des objets JVM. C’est la raison technique précise pour laquelle une UDF est dix fois plus lente qu’une fonction native.
Non, et la nuance à éviter est de répondre « parce que Spark est distribué » : les bases modernes le sont aussi. La différence est une question de cible de conception.
| Base transactionnelle | Spark SQL | |
|---|---|---|
| Optimisé pour | requêtes ponctuelles | balayages de gros volumes |
| Latence typique | millisecondes | secondes à minutes |
| Index | centraux (B-tree) | aucun — élagage de partitions et statistiques |
| ACID | cœur du produit | via Delta Lake ou Iceberg seulement |
UPDATE par ligne | naturel | coûteux, voire impossible |
| Stockage | interne, couplé | fichiers sur un lac |
Concrètement : SELECT * FROM commandes WHERE id = 84213 est une mauvaise idée en Spark SQL — sans index, il balaiera les fichiers. Et demander à PostgreSQL d’agréger huit milliards de lignes en scannant 4 To est également une mauvaise idée.
L’écart structurel à mentionner : Spark travaille sur des fichiers que vous possédez, pas sur un stockage propriétaire. La même table Parquet est lisible par Spark, Trino, DuckDB ou Athena. C’est la promesse du lac de données, et cela explique pourquoi Spark alimente les bases plutôt qu’il ne les remplace.
La formule qui conclut bien : Spark SQL sert à préparer les données, la base sert à les servir. Un pipeline typique fait les deux — Spark transforme et écrit en Parquet, une base ou un entrepôt sert les requêtes de l’application.
Delta Lake est du Parquet, plus un journal de transactions. Les données restent des fichiers Parquet ; ce qui s’ajoute est un dossier _delta_log qui décrit quels fichiers composent la table à chaque version.
Ce journal apporte cinq choses que Parquet seul ne peut pas offrir :
UPDATE, DELETE et MERGE. Indispensables pour un flux de type upsert, ou pour une suppression conforme au RGPD. En Parquet, il faut réécrire la partition entière à la main.VERSION AS OF 12 permet de relire l’état d’hier, donc d’auditer et de revenir en arrière après un mauvais chargement.S’y ajoute OPTIMIZE, qui compacte les petits fichiers, et ZORDER, qui coïmplante les valeurs proches pour rendre l’élagage plus efficace.
Le contrepoint honnête, qui évite de paraître vendeur : Delta ajoute une dépendance et un journal à entretenir — VACUUM doit être passé pour supprimer les anciens fichiers, sinon le stockage gonfle indéfiniment. Pour une table écrite une fois puis lue en lecture seule, du Parquet simple suffit très bien.
La réponse attendue n’est pas un chiffre, c’est un raisonnement — et deux règles empiriques bien connues.
4 à 5 cœurs par executor. Au-delà, le débit vers HDFS ou le stockage objet plafonne à cause de la contention sur les entrées-sorties. En dessous, on multiplie les JVM et on perd les bénéfices du partage de mémoire pour le cache et le broadcast.
Pas plus de 32 Go de tas. Au-delà, la JVM perd la compression des pointeurs d’objets, et les pauses du ramasse-miettes deviennent une source de latence. Mieux vaut plusieurs executors moyens qu’un énorme.
Le calcul concret, sur un nœud de 16 cœurs et 64 Go :
1 cœur et ~1 Go réservés au système et au démon du gestionnaire
→ 15 cœurs, 63 Go utilisables
→ 3 executors de 5 cœurs
→ 63 / 3 = 21 Go par executor
→ moins l'overhead (~10 %) : spark.executor.memory ≈ 19gLe paramètre qu’on oublie systématiquement, et qu’il faut citer : spark.executor.memoryOverhead. Il couvre les tampons de shuffle, les buffers réseau et les processus Python en PySpark. Un conteneur tué par YARN ou Kubernetes avec le code 143 vient presque toujours de là, et pas du tas.
Le dernier point, qui montre qu’on relie les réglages entre eux : le nombre total de cœurs détermine le parallélisme utile, donc le nombre de partitions doit suivre. Trois executors de 5 cœurs donnent 15 tâches en parallèle ; viser 30 à 45 partitions par étape est cohérent, et laisser spark.sql.shuffle.partitions à 200 ne l’est pas.
Et pour finir : sur un cluster partagé, l’allocation dynamique (spark.dynamicAllocation.enabled) est souvent préférable à un dimensionnement fixe, puisqu’elle rend les executors inactifs au lieu de les réserver.
La distinction technique de base : Databricks exécute avec Spark, accéléré par le moteur vectorisé Photon ; Snowflake exécute avec son propre moteur propriétaire, sur des virtual warehouses dimensionnés par taille de T-shirt.
Mais répondre uniquement sur le moteur passe à côté de la vraie question, qui est celle du type de charge :
Deux critères pratiques qui tranchent souvent plus vite que le débat technique :
Le piège à connaître sur Databricks, parce que c’est un vrai coût de production : Photon retombe silencieusement sur Spark JVM pour les opérations qu’il ne prend pas en charge — une UDF, certains types complexes. Vous payez le supplément Photon sans en bénéficier, et rien ne l’annonce sinon le plan d’exécution. C’est aussi pour cela qu’une UDF coûte deux fois sur cette plateforme.
Et la nuance de contexte : les deux produits convergent. Snowflake a ajouté Snowpark et le support d’Iceberg ; Databricks a ajouté un entrepôt SQL sérieux. Le choix se joue de moins en moins sur les capacités et de plus en plus sur l’écosystème en place.