Se leggi abbastanza blog post su Spark ti porterai via l’idea che il framework abbia tre modi completamente diversi di fare la stessa cosa, che tu debba scegliere con attenzione, e che sceglierne uno sbagliato ti costerà caro. La prima parte è mezza vera. La seconda è in larga parte falsa. La terza è genuinamente falsa nel 2026.
In pratica quasi tutti usano i DataFrame quasi sempre, e c’è una ragione molto buona per cui è così. Ma per sapere perché i DataFrame hanno vinto, devi sapere cosa hanno rimpiazzato e cosa offrivano che l’API originale non offriva. Quindi vediamo i tre, nell’ordine in cui sono apparsi.
Un po’ di storia, perché spiega tutto
Spark è nato all’AMPLab di UC Berkeley intorno al 2009, è stato reso open-source nel 2010, ed è diventato un progetto top-level di Apache nel 2014. L’astrazione originale, quella che il paper del 2010 di Matei Zaharia ha introdotto, era il Resilient Distributed Dataset, l’RDD. Per i primi tre anni, era l’intera API di Spark.
Poi a marzo 2015 Spark 1.3 ha rilasciato l’API DataFrame. Sembrava un DataFrame R o pandas, ma distribuito: righe, colonne con nome, uno schema, operazioni in stile SQL. Sotto c’era una cosa nuova di zecca chiamata Catalyst optimizer.
A gennaio 2016, Spark 1.6 ha aggiunto l’API Dataset, che era DataFrame più type safety a tempo di compilazione in Scala e Java. In Spark 2.0 (luglio 2016) i tre sono stati unificati a livello di API: DataFrame è diventato ufficialmente Dataset[Row] in Scala, ma la distinzione concettuale è ancora viva e vale la pena conoscerla.
Tre API. Un motore. Astrazioni diverse sopra lo stesso modello di esecuzione.
RDD: l’originale
Un RDD è, in sostanza, una collezione partizionata di oggetti arbitrari. Se hai usato le liste Python, un RDD è “una lista, con la differenza che è suddivisa in partizioni, e ogni pezzo vive su una macchina diversa, e puoi fare map e filter in parallelo”.
# Questo è codice RDD. Raramente lo scriviamo ancora.
rdd = spark.sparkContext.parallelize(range(1_000_000), numSlices=8)
result = (
rdd.map(lambda x: x * 2)
.filter(lambda x: x % 3 == 0)
.reduce(lambda a, b: a + b)
)
Tre cose di quello snippet sono caratteristiche del codice RDD. Primo, le operazioni (map, filter, reduce) prendono funzioni arbitrarie, lambda Python, in questo caso. Secondo, il tipo di dato è quello che ci metti dentro: interi, tuple, classi tue, dict, qualunque cosa Python possa picklare. Terzo, non c’è schema. L’RDD non ha idea di cosa ci sia dentro; semplicemente si fida che la tua lambda x: x * 2 farà qualcosa di sensato.
Quella flessibilità è anche la debolezza. Siccome le operazioni sono funzioni Python opache, Spark non può guardarci dentro. Non può riscrivere map(f).filter(g) in qualcosa di più furbo di “applica f a ogni riga, poi applica g a ogni riga”. Non ha idea se f legga tutta la riga o solo una colonna. Non può fare push-down dei filtri. Non può ottimizzare le join perché non sa cosa sia una “join key”: per un RDD, una join è solo uno shuffle generico di coppie (K, V).
Cosa hanno dato al mondo gli RDD:
- Una primitiva di parallel-processing generale. Potevi usare Spark per fare cose che non sono per niente tabulari: traversal di grafi, iterazioni ML, processing di stringhe, lavoro geospaziale.
- Fault tolerance via lineage. Ogni RDD si ricorda come è stato derivato. Se una partizione viene persa, Spark la ricalcola dalla sorgente.
- Controllo di basso livello. Potevi scegliere il tuo partitioner, una serializzazione custom, broadcast variable, accumulator, tutto quanto.
Quando ricorrere agli RDD nel 2026:
- Un algoritmo specifico che richiede controllo fine sul partizionamento (es. un partitioner custom per una distribuzione di chiavi specifica del dominio).
- Algoritmi su grafi che non si adattano bene ai DataFrame. Anche se qui GraphFrames (basato su DataFrame) si è mangiato la maggior parte dei casi d’uso.
- Serializzazione custom per oggetti che non rientrano nel modello dello schema dei DataFrame, ad esempio grandi blob binari con pattern di accesso inusuali.
- Codice legacy che stai mantenendo. C’è un sacco di codice RDD ancora in produzione dell’era 2014-2017.
Per il 99% del lavoro analitico (leggere file, filtrare, fare join, aggregare, scrivere) gli RDD sono lo strumento sbagliato. Sono più difficili da scrivere, più lenti da eseguire, e l’ottimizzatore non può aiutarti.
DataFrame: l’API che ha vinto
Un DataFrame è una tabella distribuita con colonne nominate e uno schema. Si presenta così:
df = spark.read.parquet("s3://runehold/orders/")
(df.filter(df.country == "IT")
.groupBy("product_id")
.agg({"amount": "sum"})
.show())
Tre cose sono diverse rispetto al codice RDD. Primo, le operazioni (filter, groupBy, agg) sono dichiarative: descrivono cosa vuoi, non come calcolarlo. Secondo, le colonne sono nominate: df.country, df.amount. Terzo, c’è uno schema: Spark sa che country è una stringa, amount è un double, e così via.
Il motivo per cui tutto questo conta è Catalyst, l’ottimizzatore di query di Spark. Quando scrivi df.filter(df.country == "IT").groupBy("product_id").agg(...), Spark non esegue effettivamente quei passaggi in quell’ordine. Costruisce un piano logico, poi lo passa attraverso Catalyst, che:
- Spinge il filtro dentro la sorgente dati. Se stai leggendo Parquet, il filtro su
countrydiventa parte del file scan: Spark legge solo i row group dovecountry = 'IT'è possibile. Puoi risparmiare il 99% dell’I/O su un dataset partizionato come si deve. - Pruna le colonne. Se usi solo
country,product_ideamount, Spark legge dal disco solo quelle colonne. Il layout colonnare di Parquet rende questo praticamente gratis. - Sceglie la giusta strategia di join. Broadcast hash join quando un lato è piccolo. Sort-merge join quando entrambi sono grandi. Shuffle hash join in circostanze specifiche. L’ottimizzatore prende questa decisione sulla base delle statistiche, non di quello che hai scritto.
- Riordina le operazioni. Filtri prima delle join. Aggregazioni prima delle join dove possibile. Constant folding. Common subexpression elimination.
- Genera codice whole-stage. Questa è la parte magica. Catalyst prende il piano fisico e genera bytecode Java a runtime che fonde molte operazioni in un unico ciclo stretto. Una catena
filter -> map -> filter -> sumnon gira come quattro passi; gira come uno solo. È per questo che Spark con DataFrame sulla JVM è grossomodo veloce quanto Java scritto a mano che fa la stessa cosa.
Il divario di performance tra RDD e DataFrame per un tipico lavoro analitico non è sottile. È regolarmente 5x-50x a favore dei DataFrame, e scala con la furbizia delle operazioni che stai facendo. Un semplice count è simile tra i due. Una multi-join complessa con filtri e aggregazioni è drasticamente più veloce con i DataFrame.
Il deep-dive di Databricks su Catalyst (linkato in fondo) è il writeup tecnico originale, ed è ancora la spiegazione più chiara se vuoi capire esattamente come funziona l’ottimizzatore.
I DataFrame parlano anche SQL. Tutto quello che puoi fare con l’API DataFrame, lo puoi fare anche con spark.sql("SELECT ..."). I due sono interscambiabili: stesso Catalyst, stessa esecuzione. Molte codebase di produzione mescolano liberamente i due: API DataFrame per la manipolazione dei dati, SQL per le query analitiche vere e proprie perché l’SQL è più facile da leggere per gli analisti.
Dataset: il cugino tipato
In Scala (e Java), un Dataset è un DataFrame tipato. Definisci una case class, e il Dataset sa che le colonne sono esattamente quei campi con quei tipi:
case class Order(orderId: Long, country: String, amount: Double)
val orders: Dataset[Order] = spark.read.parquet("...").as[Order]
orders.filter(_.country == "IT") // tipato: _.country è una String
.map(_.amount * 1.22) // tipato: ritorna Dataset[Double]
Il vantaggio è la type safety a tempo di compilazione. Se scrivi _.cuontry (refuso), il compilatore Scala lo cattura prima ancora che tu sottometta il job. Se scrivi _.amount.toUpperCase (insensato), il compilatore lo cattura. Refattorizzare un rinomino di colonna diventa un errore di compilazione in ogni punto che ha bisogno di essere aggiornato.
Il costo è che quando usi l’API tipata (map, filter con lambda che operano su Order), Catalyst non può vedere dentro la lambda: stesso problema degli RDD. Ottieni type safety, perdi un po’ di ottimizzazione. La maggior parte dei team Scala usa un mix: operazioni DataFrame non tipate dove Catalyst può aiutare, operazioni Dataset tipate dove la type safety vale più dell’ottimizzazione.
In PySpark, i Dataset non esistono. Python è tipato dinamicamente; non c’è un sistema di tipi a tempo di compilazione da far rispettare. Il DataFrame di PySpark è concettualmente equivalente al Dataset[Row] di Scala. Quando leggi la documentazione PySpark che dice “questo restituisce un DataFrame”, e leggi la documentazione Scala che dice “questo restituisce un Dataset[Row]”, quelle sono la stessa cosa.
Questo è un sollievo, in realtà. Gli utenti PySpark hanno un’astrazione in meno da imparare. Abbiamo DataFrame e RDD, e basta.
Cosa significa questo per il tuo PySpark di tutti i giorni
Tre regole pratiche:
Regola 1: Usa i DataFrame. Questa è l’API. È intorno a questa che è scritta la documentazione, è per questa che è costruito l’ottimizzatore, è questa che ogni code review PySpark si aspetterà. Se ti trovi a tendere la mano verso gli RDD, chiediti perché tre volte prima di farlo.
Regola 2: Usa funzioni native di Spark, non lambda Python, ogni volta che è possibile. Dentro l’API DataFrame, ci sono due modi di esprimere una trasformazione. Il modo veloce: from pyspark.sql import functions as F e poi F.upper(F.col("name")), F.when(...), F.regexp_replace(...). Queste sono operazioni JVM-side, completamente visibili a Catalyst, niente Python coinvolto. Il modo lento: una UDF Python (decoratore @udf) che processa una riga alla volta in un Python worker. La JVM e Python devono parlarsi per ogni riga. Lo vediamo nella prossima lezione.
Regola 3: Scendi agli RDD solo con una ragione specifica. “Mi piace di più la sintassi” non è una ragione. “Ho bisogno di un partitioner custom che nessuna primitiva DataFrame può esprimere” è una ragione. “Abbiamo un modulo legacy da 4.000 righe scritto contro gli RDD” è una ragione. “Sto portando codice GraphX” è una ragione. Al di fuori di queste, scrivi DataFrame.
In una tipica codebase di data engineering in stile Runehold (leggere parquet, fare join con dati di reference, aggregare, riscrivere) puoi passare anni senza toccare l’API RDD. L’unica volta che la maggior parte dei team incontra gli RDD è quando fanno .rdd su un DataFrame per ispezionare il partizionamento, o quando una vecchia risposta su StackOverflow suggerisce qualcosa che si rivela essere annata 2017.
Confronto rapido
| Aspetto | RDD | DataFrame | Dataset (Scala) |
|---|---|---|---|
| Astrazione | Collezione partizionata di oggetti | Tabella distribuita con schema | Tabella distribuita tipata |
| Ottimizzatore | Nessuno | Catalyst | Catalyst (op non tipate) |
| Schema | No | Sì | Sì (a tempo di compilazione) |
| Type safety | Solo a runtime | Solo a runtime | A tempo di compilazione |
| Disponibile in PySpark | Sì | Sì | No (= DataFrame) |
| Performance per analytics | Baseline | 5-50x più veloce | Come DataFrame |
| Quando usarlo | Nicchia / legacy | Default | Team Scala che vogliono i tipi |
Quella tabella è il tuo cheat sheet. Attaccala al monitor per il primo mese e poi buttala via: per la lezione 20 te la sarai memorizzata.
Prossima lezione: PySpark vs Scala Spark, e cosa attraversa davvero il filo quando chiami .filter() da Python.
References
- Apache Spark — RDD Programming Guide: https://spark.apache.org/docs/latest/rdd-programming-guide.html
- Apache Spark — SQL, DataFrames and Datasets Guide: https://spark.apache.org/docs/latest/sql-programming-guide.html
- Databricks — Deep Dive into Spark SQL’s Catalyst Optimizer: https://databricks.com/blog/2015/04/13/deep-dive-into-spark-sqls-catalyst-optimizer.html
- Zaharia et al. — Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing (NSDI 2012): https://www.usenix.org/conference/nsdi12/technical-sessions/presentation/zaharia
Retrieved 2026-05-01.