Oui, Spark relit tout le fichier pour deviner les types. Mais le vrai problème n’est pas la lenteur : c’est le type qui change et casse le pipeline sans erreur.
La question se pose dès le premier CSV un peu gros : est-ce que inferSchema lit tout le fichier, ou seulement les premières lignes ?
val df = spark.read
.option("header", "true")
.option("inferSchema", "true")
.csv("gros-fichier.csv")Il lit tout. Pour un CSV, Spark effectue une passe complète supplémentaire sur les données, uniquement pour déterminer les types, avant que votre traitement ne commence. Sur 500 Go, vous payez donc deux balayages là où un seul était nécessaire : un pour deviner, un pour travailler.
Mais si vous vous arrêtez à ce constat, vous corrigez le petit problème et vous gardez le grand.
L’enchaînement est celui-ci :
inferSchema = true schéma fourni
────────────────── ────────────────
fichier 500 Go fichier 500 Go
↓ ↓
lecture pour DEVINER ← passe 1 Spark connaît déjà les types
↓ ↓
nom → String traitement ← passe unique
age → Integer
salaire → Double
↓
traitement ← passe 2Sur un fichier d’exemple, la différence est invisible. Sur un lac de données en S3, elle se chiffre : temps de calcul doublé sur la phase de lecture, et surtout deux fois les requêtes GET facturées par le stockage objet. C’est le genre de ligne qui grossit sans que personne ne sache pourquoi.
La correction tient en une option :
import org.apache.spark.sql.types._
val schema = StructType(Array(
StructField("nom", StringType, true),
StructField("age", IntegerType, true),
StructField("salaire", DoubleType, true),
))
val df = spark.read
.option("header", "true")
.schema(schema)
.csv("gros-fichier.csv")schema() accepte aussi une chaîne DDL, ce qui évite dix lignes de StructField :
val df = spark.read
.option("header", "true")
.schema("nom STRING, age INT, salaire DOUBLE")
.csv("gros-fichier.csv")Même chose en PySpark :
df = (spark.read
.option("header", True)
.schema("nom STRING, age INT, salaire DOUBLE")
.csv("gros-fichier.csv"))Pour les structures imbriquées, la syntaxe suit : client STRUCT<nom: STRING, age: INT>, tags ARRAY<STRING>. Au-delà de trois niveaux, StructType redevient plus lisible.
Voici le vrai danger, et il ne se voit pas en développement.
inferSchema décide des types à partir des données du jour. Une colonne qui ne contient que des nombres entiers est typée Integer. Le lendemain, un système en amont écrit N/A dans une seule ligne sur dix millions — et Spark type la colonne en String.
Votre code ne change pas. Votre pipeline casse quand même :
df.filter($"age" > 18) // hier : comparaison numérique
// aujourd'hui : comparaison de chaînesEt il ne casse pas toujours bruyamment, ce qui est pire. Comparer des chaînes se fait dans l’ordre lexicographique :
"9" > "18" → true (parce que "9" > "1")Le job réussit, produit des chiffres faux, et personne ne s’en aperçoit avant le rapport de fin de mois. Un schéma explicite aurait échoué immédiatement sur la ligne N/A, ce qui est exactement le comportement souhaitable.
C’est la raison de fond de préférer un schéma déclaré : pas la performance, la stabilité du contrat. Le schéma cesse d’être une conséquence des données et devient une décision que vous versionnez avec le code.
inferSchema = falseAttention à ne pas conclure « je vais simplement l’enlever ». Sans l’option et sans schéma, tout est lu en String — c’est la valeur par défaut pour le CSV :
val df = spark.read.option("header", "true").csv("etudiants.csv")
df.printSchema()
// root
// |-- nom: string (nullable = true)
// |-- age: string (nullable = true) ← pas un entier
// |-- salaire: string (nullable = true) ← pas un doubleVous héritez alors du même bug de comparaison lexicographique, en permanence. Les trois postures possibles sont donc :
| Réglage | Passes de lecture | Types | Pour |
|---|---|---|---|
| Rien | 1 | Tout en String | À éviter |
inferSchema = true | 2 | Devinés, variables | Exploration, petits fichiers |
schema(...) | 1 | Déclarés, stables | Production |
Écrire un StructType à la main pour quarante colonnes est pénible, et c’est pour cela que beaucoup gardent inferSchema en production. Le raccourci : laissez Spark deviner une seule fois, sur un échantillon, puis figez le résultat.
// Une fois, en développement, sur un extrait du fichier
val echantillon = spark.read
.option("header", "true")
.option("inferSchema", "true")
.csv("extrait-10000-lignes.csv")
println(echantillon.schema.json) // à copier dans le dépôtPuis en production, on relit ce schéma sans jamais toucher aux données :
import org.apache.spark.sql.types.{DataType, StructType}
val schema = DataType
.fromJson(scala.io.Source.fromResource("schema-clients.json").mkString)
.asInstanceOf[StructType]
val df = spark.read.option("header", "true").schema(schema).csv("s3a://bucket/clients/")Le schéma vit désormais dans Git. Il se relit, se revoit en revue de code, et tout changement de structure en amont devient un échec explicite au lieu d’une dérive silencieuse.
Le JSON souffre du même problème, et l’inférence y est encore plus coûteuse puisqu’il faut examiner les clés de chaque objet. Une option permet de n’échantillonner qu’une fraction des données :
df = spark.read.option("samplingRatio", 0.1).json("s3a://bucket/events/")C’est un compromis, pas une solution : sur 10 % des lignes, un champ rare peut être absent de l’échantillon et donc absent du schéma — les données correspondantes seront alors silencieusement perdues. En production, le schéma explicite reste la bonne réponse.
Le JSON a par ailleurs son propre piège de lecture, indépendant des types : Spark attend du JSON Lines et répond _corrupt_record sur un tableau formaté, sans faire échouer le job. C’est détaillé dans les vues temporaires Spark.
Toute cette discussion est un symptôme. Le CSV est un format qui ne transporte pas ses types : il n’y a que du texte séparé par des virgules, donc quelqu’un doit deviner. Le débat inferSchema disparaît entièrement dès qu’on change de format.
val df = spark.read.parquet("s3a://bucket/clients/")
// Aucune option. Aucune passe supplémentaire. Aucune dérive de type.Parquet écrit le schéma dans le pied de fichier. Spark le lit en quelques kilo-octets, sans toucher aux données, et les types sont ceux qui ont été écrits — pas ceux qu’on a supposés. S’ajoutent la compression par colonne, le saut de colonnes inutiles et le refoulement des filtres vers la source.
La règle de production qui en découle : le CSV est un format d’échange, pas un format de stockage. Convertissez-le une fois à l’arrivée, avec un schéma explicite, puis ne relisez plus que du Parquet ou du Delta :
spark.read
.option("header", "true")
.schema("nom STRING, age INT, salaire DOUBLE")
.csv("s3a://bucket/entrant/clients.csv")
.write.mode("overwrite")
.parquet("s3a://bucket/propre/clients/")Le choix entre Parquet et Delta, le partitionnement et la taille des fichiers sont traités dans écrire en Spark : Parquet, Delta Lake, petits fichiers.
inferSchema = true relit tout le fichier pour deviner les types, ce qui double la lecture. C’est le coût visible, et il suffit d’un schema() pour l’effacer. Le coût réel est ailleurs : un schéma deviné dépend des données du jour, donc il change sans prévenir, et une colonne Integer devenue String produit des comparaisons lexicographiques qui renvoient des résultats faux sans lever d’erreur.
En pratique : inferSchema pour explorer, schéma déclaré et versionné en production, et pour tout ce qui est relu régulièrement, un format qui porte son propre schéma. Le débat ne concerne que le CSV.
Développement et déploiement de solutions de données — les premiers modules sont en accès libre.