Un DataFrame è due cose imbullonate insieme: un sacco distribuito di righe, e uno schema, ovvero i nomi delle colonne e i tipi che dicono come sono fatte quelle righe. I dati senza lo schema sono solo byte opachi. Lo schema senza i dati è un contratto. A Spark servono entrambi, ogni volta.
La lezione di oggi parla di dove arriva quello schema. Le risposte sono esattamente due, Spark lo indovina per te oppure te lo scrivi a mano, e la scelta tra le due è una di quelle piccole decisioni che fa la differenza tra un notebook che gira in 30 secondi e un job di produzione che gira in 30 minuti.
Cosa c’è dentro uno schema
df.schema è uno StructType. Uno StructType è semplicemente una lista di oggetti StructField, ognuno con tre cose: un nome, un tipo di dato, e un flag nullable. Tutto qui. Non c’è magia, non c’è un layer di metadati, non c’è stato nascosto: quando Spark pianifica una query, attraversa questo oggetto per capire quali colonne esistono e come leggerle.
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("schemas").getOrCreate()
df = spark.read.csv("orders.csv", header=True, inferSchema=True)
df.printSchema()
# root
# |-- order_id: integer (nullable = true)
# |-- customer_id: integer (nullable = true)
# |-- amount: double (nullable = true)
# |-- ts: timestamp (nullable = true)
printSchema() ti restituisce l’albero leggibile da umani. df.schema restituisce l’oggetto StructType vero e proprio, utile quando vuoi riusare uno schema o confrontare due DataFrame in modo programmatico. df.dtypes restituisce una lista di tuple (nome, stringa_tipo), che è la forma più facile da copiare in uno script.
df.dtypes
# [('order_id', 'int'),
# ('customer_id', 'int'),
# ('amount', 'double'),
# ('ts', 'timestamp')]
Tre viste della stessa informazione. Usa quella che ti torna comoda al momento.
Un dettaglio piccolo ma utile: gli schema sono posizionali in alcune operazioni e per nome in altre. Quando fai union, Spark fa il match delle colonne per posizione di default, quindi due DataFrame con gli stessi nomi di colonna in ordine diverso produrranno silenziosamente una union spazzatura. La soluzione è unionByName. Ci arriveremo nella lezione sui join, ma è un promemoria che lo schema non è un’etichetta passiva: è come Spark identifica le colonne a ogni passo del piano.
Schema inferiti: economici quando sono piccoli, brutali quando sono grandi
Spark può capire lo schema di un file CSV o JSON leggendolo. Per il CSV lo richiedi con inferSchema=True:
df = spark.read \
.option("header", True) \
.option("inferSchema", True) \
.csv("orders.csv")
Cosa fa davvero: Spark legge l’intero file una volta, solo per guardare i valori, decide che tipo dovrebbe avere ogni colonna (int? double? string? timestamp?), butta via quel risultato, e poi legge il file di nuovo per caricare effettivamente i dati. Due passate complete sull’input.
Per un CSV esplorativo da 10MB in un notebook non te ne accorgi. Tutto finisce in un secondo. Per un dump da 500GB nella landing zone su S3, hai appena raddoppiato il costo di lettura, il tempo di lettura, e la bolletta dell’I/O, per imparare informazioni che quasi sicuramente conosci già.
JSON è anche peggio. Non c’è un’opzione inferSchema=False per JSON allo stesso modo; Spark inferisce sempre, perché JSON non ha header. Con JSON nidificato o profondamente variabile, la passata di inferenza può scansionare miliardi di record per fondere gli schema tra le righe.
L’altro problema dell’inferenza, quello che morde in produzione: i tipi inferiti sono congetture, e possono cambiare tra una run e l’altra. Supponiamo che la tua colonna customer_id sia normalmente numerica. Un giorno arriva una singola riga con il valore "unknown", una sentinella null di qualcuno che è filtrata dal sistema a monte. Spark vede una stringa in una colonna che avrebbe chiamato int, e silenziosamente allarga l’intera colonna a string. Il codice a valle, che faceva df.customer_id + 1, ora esplode. O peggio, non esplode e produce nonsense.
Lo schema drift è una vera modalità di failure. L’inferenza ti rende vulnerabile a quello.
C’è anche il puro costo di sbagliare i tipi. L’inferenza deciderà allegramente che una colonna è int quando in realtà dovrebbe essere bigint, perché le righe campione che ha visto stavano tutte in 32 bit. Poi un mese dopo arriva una riga con un id sopra i due miliardi e il cast va in overflow. O decide che una colonna data è string perché una riga aveva "N/A". L’inferenza è ottimista in un modo che i sistemi di produzione non premiano.
Schema espliciti: il default di produzione
La soluzione è dichiarare lo schema da soli e passarlo. A quel punto Spark non indovina: fa il parsing di ogni valore nel tipo che hai specificato, e le righe che non ci stanno diventano null (con mode="PERMISSIVE", il default) o fanno fallire la lettura (con mode="FAILFAST").
La forma verbosa usa StructType e StructField:
from pyspark.sql.types import (
StructType, StructField,
IntegerType, DoubleType, StringType, TimestampType,
)
orders_schema = StructType([
StructField("order_id", IntegerType(), nullable=False),
StructField("customer_id", IntegerType(), nullable=False),
StructField("amount", DoubleType(), nullable=True),
StructField("ts", TimestampType(), nullable=True),
])
df = spark.read \
.option("header", True) \
.schema(orders_schema) \
.csv("orders.csv")
Una passata di lettura. Tipi prevedibili. Se una riga ha "banana" nella colonna amount, quel singolo valore diventa null; il resto della riga viene comunque parsato. Se preferisci che il job fallisca rumorosamente quando succede, aggiungi .option("mode", "FAILFAST").
La forma più corta usa una stringa DDL. È la stessa informazione, con meno cerimonia:
orders_schema = "order_id INT, customer_id INT, amount DOUBLE, ts TIMESTAMP"
df = spark.read \
.option("header", True) \
.schema(orders_schema) \
.csv("orders.csv")
Per gli schema piatti preferisco la stringa DDL. Sono due righe invece di sette, si legge come un’istruzione CREATE TABLE, ed è diff-friendly: quando qualcuno aggiunge una colonna, il diff è una riga, non cinque.
Per dati profondamente nidificati (array di struct, mappe di struct, eccetera), la forma verbosa è più chiara. Usa quella che si adatta meglio.
Il flag nullable è principalmente documentazione
Noterai che ogni StructField accetta un argomento nullable. Potresti pensare che impostarlo a False faccia rifiutare a Spark le righe in cui quella colonna è null.
Non lo fa. O meglio, lo fa a volte, e altre volte no, e il comportamento non è quello che speri.
nullable=False è un suggerimento per l’ottimizzatore Catalyst. Dice a Spark “puoi assumere che questa colonna non sia mai null quando pianifichi query su di essa”, il che permette all’ottimizzatore di saltare i percorsi di codice che gestiscono i null. Se di fatto gli passi un null dove avevi promesso non ce ne fossero, non ottieni un errore pulito. Ottieni un comportamento indefinito: a volte la riga viene silenziosamente scartata, a volte crasha in profondità in un code generator, a volte passa indisturbata.
La conclusione: imposta nullable=True (il default) su tutto a meno che tu non sia sicuro al 100%. Se vuoi imporre il non-null, fallo esplicitamente con un filtro o un controllo dopo la lettura:
bad_rows = df.filter(df.order_id.isNull())
if bad_rows.count() > 0:
raise ValueError(f"Got {bad_rows.count()} rows with null order_id")
Quello è un controllo che davvero scatta. Il flag nullable=False è un contratto che Spark non farà rispettare per te.
Quando inferire va bene
Non sto dicendo di non inferire mai. Ci sono casi in cui è lo strumento giusto:
- Esplorazione una tantum in un notebook. Hai un file, non sai cosa c’è dentro, vuoi guardare. L’inferenza è più veloce di scrivere uno schema per dati che butterai via tra dieci minuti.
- File abbastanza piccoli che la doppia lettura non importa. Sotto i cento megabyte, su disco locale, passerai più tempo a scrivere lo schema che a far girare la seconda passata.
- Parquet, ORC, Delta, Avro. Questi formati incapsulano lo schema nel file stesso. Spark lo legge dal footer essenzialmente a costo zero. Non c’è un passo di inferenza. Leggere Parquet “senza schema” è fondamentalmente diverso dal leggere CSV senza schema. Nessuna penalità a due passate, nessun rischio di drift.
Quindi la regola è più stretta di “dichiara sempre”. È:
Per i formati testuali senza schema (CSV, JSON) in produzione, dichiara sempre. Per i formati binari autodescrittivi (Parquet, ORC, Delta, Avro), lascia che sia il file a dirtelo.
Questo è uno dei motivi per cui il mondo del data engineering sposta CSV/JSON a Parquet appena può. Lo schema diventa una proprietà dei dati, non una cosa che devi mantenere in un file Python separato che deriva fuori sincrono dalla realtà.
Leggere quello che Spark ti ha dato
Quando ti viene consegnato un DataFrame che arriva da qualche altra parte, una join, una chiamata di funzione, un notebook iniziato da un collega, i tre metodi di ispezione hanno tutti il loro posto:
df.printSchema() # vista ad albero, per umani
df.schema # oggetto StructType, per il codice
df.dtypes # lista di tuple (nome, tipo), per copia-incolla rapido
printSchema() è quello che userai l’80% delle volte. È il diagnostico. Quando un’operazione a valle fallisce con un errore di tipo, questa è la prima cosa da controllare.
df.schema è quello che userai quando scrivi codice riusabile. Puoi salvare uno schema su disco come JSON e ricaricarlo:
schema_json = df.schema.json()
# {"fields":[{"name":"order_id","type":"integer","nullable":false,...}], ...}
# Dopo, da qualche altra parte:
from pyspark.sql.types import StructType
import json
restored = StructType.fromJson(json.loads(schema_json))
È così che funzionano sotto il cofano i tool di schema-registry e i layer di contract-checking. Buono sapere che esiste; non lo scriverai a mano spesso.
Un pattern che vale la pena conoscere: quando lo schema di un job è il contratto tra due team, salva lo schema come file .json committato in Git, accanto al codice che legge i dati. Le letture caricano lo schema da disco via StructType.fromJson(...). I cambi di schema diventano diff Git che passano per la code review come qualsiasi altra modifica. È così che trasformi “lo schema” da conoscenza tribale in qualcosa che un nuovo assunto può trovare il primo giorno.
Fai girare questo sulla tua macchina
Buttalo in un notebook o in uno script. Genererà un piccolo CSV, e poi lo leggerà in tre modi diversi così puoi vedere la differenza coi tuoi occhi.
from pyspark.sql import SparkSession
from pyspark.sql.types import (
StructType, StructField,
IntegerType, DoubleType, StringType, TimestampType,
)
from pathlib import Path
spark = SparkSession.builder.appName("schemas-demo").getOrCreate()
# Make a tiny CSV with a deliberately tricky row
csv_path = Path("/tmp/orders_demo.csv")
csv_path.write_text(
"order_id,customer_id,amount,ts\n"
"1,42,59.00,2026-01-03 10:32:00\n"
"2,42,29.00,2026-01-04 14:22:00\n"
"3,17,banana,2026-01-05 09:15:00\n" # bad amount
"4,8,149.00,2026-01-06 11:40:00\n"
)
# 1. No schema, no inference: everything is a string
df1 = spark.read.option("header", True).csv(str(csv_path))
print("=== Default (everything string) ===")
df1.printSchema()
df1.show()
# 2. Inferred: Spark guesses, and guesses wrong because of 'banana'
df2 = spark.read.option("header", True).option("inferSchema", True).csv(str(csv_path))
print("=== Inferred ===")
df2.printSchema()
df2.show()
# Notice: amount becomes string, because of one bad row.
# 3. Explicit DDL string: amount is DOUBLE, the bad row becomes null
schema_ddl = "order_id INT, customer_id INT, amount DOUBLE, ts TIMESTAMP"
df3 = spark.read.option("header", True).schema(schema_ddl).csv(str(csv_path))
print("=== Explicit (DDL) ===")
df3.printSchema()
df3.show()
# 4. Explicit StructType: same result, more verbose
schema_obj = StructType([
StructField("order_id", IntegerType()),
StructField("customer_id", IntegerType()),
StructField("amount", DoubleType()),
StructField("ts", TimestampType()),
])
df4 = spark.read.option("header", True).schema(schema_obj).csv(str(csv_path))
print("=== Explicit (StructType) ===")
df4.printSchema()
df4.show()
Fallo girare. Guarda che tipo ha preso amount in ciascun caso. La versione inferita che si allarga a stringa per via di una sola riga sbagliata è tutto l’argomento di produzione per dichiarare gli schema, in otto caratteri visibili.
Prossima lezione: select e filter, le due operazioni che farai migliaia di volte, inclusi i quattro modi diversi di riferirsi a una colonna, di cui solo tre sono sicuri.
Riferimento: Apache Spark Python API (https://spark.apache.org/docs/latest/api/python/), recuperato il 2026-05-01.