inferSchema en Spark : ce que cette option coûte

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.

6 min de lecturesparkscalapysparkcsvschemabig-data

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 ?

scala
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.

Le coût visible : une passe de trop

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 2

Sur 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 :

scala
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")
Une écriture beaucoup plus courte pour le même schéma

schema() accepte aussi une chaîne DDL, ce qui évite dix lignes de StructField :

scala
val df = spark.read
  .option("header", "true")
  .schema("nom STRING, age INT, salaire DOUBLE")
  .csv("gros-fichier.csv")

Même chose en PySpark :

python
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.

Le coût invisible : le type qui change

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 :

scala
df.filter($"age" > 18)          // hier : comparaison numérique
                                // aujourd'hui : comparaison de chaînes

Et 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.

Le piège de inferSchema = false

Attention à 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 :

scala
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 double

Vous héritez alors du même bug de comparaison lexicographique, en permanence. Les trois postures possibles sont donc :

RéglagePasses de lectureTypesPour
Rien1Tout en StringÀ éviter
inferSchema = true2Devinés, variablesExploration, petits fichiers
schema(...)1Déclarés, stablesProduction

L’astuce : deviner une fois, réutiliser toujours

É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.

scala
// 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ôt

Puis en production, on relit ce schéma sans jamais toucher aux données :

scala
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.

Pour JSON, la même logique avec une nuance

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 :

python
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.

La vraie solution : arrêter de lire du CSV

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.

scala
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 :

scala
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.

Ce qu’il faut retenir

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.

Ce sujet fait partie d’un cours complet

Développement et déploiement de solutions de données — les premiers modules sont en accès libre.

Voir le plan du cours

Continuer sur le même sujet