Nella lezione scorsa abbiamo sostenuto che Parquet è il default giusto per il lavoro analitico. La maggior parte dei team si ferma a leggere lì e non indaga sul resto. È in larga parte corretto, ma ogni tanto ti capita una pipeline in cui Parquet non è la risposta, e dovresti sapere perché.
Tre famiglie di formati dominano lo spazio del “non Parquet”:
- ORC — il fratello maggiore di Parquet, nato nell’ecosistema Hive.
- Avro — un formato row-oriented ottimizzato per streaming ed evoluzione di schema.
- Delta Lake / Iceberg / Hudi — formati di tabella transazionali che stanno sopra Parquet e aggiungono la semantica da database che gli manca.
Ognuno vince in una situazione specifica. Questa lezione passa in rassegna tutti e tre così che tu possa riconoscere quando sei in quella situazione e agire di conseguenza.
ORC: il nativo Hive
ORC sta per Optimized Row Columnar, ed è strutturalmente molto simile a Parquet. I file si dividono in stripe (i row group di Parquet), gli stripe si dividono in stream di colonna (i column chunk), ogni stream ha la sua compressione e un footer salva statistiche a livello di stripe. Se strizzassi gli occhi sul layout di un file ORC accanto a quello di un file Parquet, faresti fatica a distinguerli.
# La lettura è identica a Parquet
df = spark.read.orc("s3://lake/orders_orc/")
# Anche la scrittura
(df.write
.mode("overwrite")
.option("compression", "zstd")
.orc("s3://lake/orders_orc/"))
Il motivo per cui esistono entrambi i formati ha a che fare con la storia. ORC è stato costruito dentro Hortonworks per Hive; Parquet è stato costruito dentro Twitter e Cloudera, originariamente per Impala. Per qualche anno hanno avuto punti di forza genuinamente diversi: il predicate pushdown di ORC e i suoi indici interni (un concetto “min/max + bloom filter” che aveva prima che Parquet recuperasse) gli davano un vantaggio su certi carichi Hive, mentre Parquet aveva un supporto cross-language migliore e una storia più solida sui tipi annidati.
Nel 2026 quelle differenze si sono ridotte. Il vectorized reader di Parquet è eccellente. Parquet ha ottenuto i bloom filter. ORC ha ottenuto una migliore integrazione con Spark. I due formati danno performance grossomodo equivalenti sulla maggior parte dei benchmark, con casi limite che oscillano da una parte o dall’altra a seconda della forma dei dati e del codec.
Quello che non si è ridotto è la gravità di ecosistema. Parquet ha vinto l’ecosistema analitico più ampio: Pandas, DuckDB, Polars, BigQuery External Tables, Athena, il supporto a file esterni di Snowflake, dbt. Quasi tutto legge Parquet first-class. ORC è letto ovunque ma trattato come cittadino di seconda classe negli strumenti che non sono Hive-adiacenti.
La guida pratica: default a Parquet a meno che tu non sia in un negozio Hive dove ORC è lo standard esistente. Se lavori in un ambiente Cloudera o Hortonworks con anni di tabelle ORC, continua a usare ORC: il costo della conversione non vale il 5% di performance. Se stai partendo da zero (greenfield), scegli Parquet e non guardare indietro.
Avro: quando row-oriented è la forma giusta
Avro è il fuori posto in questo elenco. È row-oriented, non columnar. I record sono salvati uno dopo l’altro, i campi di ciascun record impacchettati in modo contiguo. Leggere la colonna 3 significa leggere ogni record per intero. La column projection è finta: leggi i byte e scarti quelli che non vuoi.
Sembra un passo indietro, e per l’analitica lo è. Allora perché esiste Avro?
Due motivi: scritture append a bassa latenza ed evoluzione di schema.
Quando ingerisci eventi Kafka uno alla volta e devi salvarli in modo durabile man mano che arrivano, non puoi bufferizzare 128 MB in memoria aspettando che un row group si riempia. Avro ti permette di scrivere un singolo record e flusharlo. Il formato di file è progettato per append in streaming. È per questo che le pipeline “topic Kafka archiviato su S3” atterrano quasi sempre in Avro, e poi vengono ricompattate in Parquet a valle da un job batch.
La storia dell’evoluzione di schema è ancora più importante. Avro salva il proprio schema con i dati: ogni file Avro dichiara lo schema del writer nell’header. I reader confrontano lo schema del writer con il proprio schema e risolvono le differenze automaticamente, seguendo regole di compatibilità ben definite. Puoi:
- Aggiungere un campo con un valore di default. I vecchi reader lo ignorano; i nuovi vedono il default per i record vecchi.
- Rimuovere un campo che ha un default. I vecchi reader vedono il default; i nuovi smettono di leggerlo.
- Rinominare un campo via alias. Il vecchio codice continua a funzionare col vecchio nome.
Questo tipo di compatibilità avanti-e-indietro è essenziale per lo streaming di eventi, dove producer e consumer fanno deploy in modo indipendente e non puoi coordinare i cambi di schema tra team. Il pattern si abbina a un Schema Registry (quello di Confluent è l’implementazione canonica) che salva gli schemi centralmente e assegna a ognuno un ID. I record Avro sul filo referenziano l’ID dello schema, non lo schema completo, mantenendoli piccoli.
Leggere e scrivere Avro con Spark richiede il pacchetto spark-avro, incluso nelle distribuzioni standard di Spark, ma potresti dover dichiararlo esplicitamente:
spark = (SparkSession.builder
.appName("AvroDemo")
.config("spark.jars.packages", "org.apache.spark:spark-avro_2.12:3.5.0")
.getOrCreate())
# Read
events = spark.read.format("avro").load("s3://kafka-archive/events/")
# Write
(df.write
.format("avro")
.mode("append")
.save("s3://kafka-archive/events/"))
Quando ricorrere ad Avro:
- Stai consumando topic Kafka e scrivendo gli eventi grezzi su storage di lungo termine.
- Producer e consumer fanno deploy con tempistiche diverse e hanno bisogno di un’evoluzione di schema che non rompa nessuna delle due parti.
- I tuoi record sono tipicamente letti per intero, non proiettati per colonna.
Quando non ricorrere ad Avro:
- Query analitiche che toccano poche colonne su molte. La mancanza di column pruning farà male.
- Ovunque ricorreresti a Parquet per ragioni di performance. Il beneficio dello streaming non si applica.
Un’architettura di produzione comune: gli eventi atterrano in Avro su object storage grezzo; un job batch gira ogni ora per ricompattarli in Parquet partizionato per l’analitica. Avro al bordo, Parquet al warehouse.
Delta Lake: Parquet più un transaction log
E ora il formato che sta silenziosamente mangiando il mondo.
Delta Lake non è un nuovo formato di file: è uno strato che sta sopra Parquet. I file dati sono ancora Parquet. Quello che Delta aggiunge è un transaction log: una directory chiamata _delta_log/ accanto ai tuoi dati, piena di file JSON che registrano in ordine ogni commit (aggiungi questo file, rimuovi quello, cambia questi metadati). Ogni scrittura produce una nuova entry nel log. Ogni lettura consulta prima il log per capire quali file fanno parte della versione corrente della tabella, poi legge solo quelli.
Quella struttura sblocca quattro cose che Parquet da solo non può darti:
1. Scritture atomiche. Una scrittura Parquet su S3 elenca file scritti a metà durante la scrittura: i reader possono vedere stato parziale. Delta scrive nuovi file, poi commita atomicamente una entry di log che li rende visibili. O l’intera scrittura è visibile, o niente lo è.
2. UPDATE / DELETE / MERGE. I file Parquet sono immutabili, quindi cambiare righe significa riscrivere file. Delta automatizza tutto questo: un UPDATE riscrive i file interessati e registra nel log che i vecchi sono rimossi. I reader vedono automaticamente il nuovo stato.
from delta.tables import DeltaTable
orders = DeltaTable.forPath(spark, "s3://lake/orders_delta/")
# UPDATE righe in place
orders.update(
condition="status = 'pending' AND age_days > 7",
set={"status": "'expired'"},
)
# DELETE righe
orders.delete(condition="amount = 0")
3. MERGE INTO (upsert). La feature killer. Combina insert e update in una sola operazione, che è esattamente ciò di cui ogni pipeline CDC (change-data-capture) ha bisogno:
updates = spark.read.parquet("s3://staging/orders_changes/")
target = DeltaTable.forPath(spark, "s3://lake/orders_delta/")
(target.alias("t")
.merge(updates.alias("u"), "t.order_id = u.order_id")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute())
Prima di Delta, questo pattern richiedeva una riscrittura completa della tabella o un job di windowing complicato. Ora sono tre righe.
4. Time travel. Poiché il log registra ogni versione della tabella, puoi leggere qualunque versione passata per numero di versione o timestamp:
# A 2 giorni fa
old = spark.read.format("delta").option("timestampAsOf", "2026-04-21").load(path)
# Alla versione 47
old = spark.read.format("delta").option("versionAsOf", 47).load(path)
Il time travel è inestimabile per il debugging (“come era la tabella prima del job sbagliato di ieri?”) e per la riproducibilità (“il modello è stato addestrato esattamente su questo snapshot”). Non è gratis: i vecchi file sono tenuti finché non vengono fatti vacuum, mangiando storage; ma il valore operativo per il debugging è difficile da sopravvalutare.
Lettura e scrittura sono dirette una volta aggiunto il pacchetto:
spark = (SparkSession.builder
.config("spark.jars.packages", "io.delta:delta-spark_2.12:3.1.0")
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
.config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
.getOrCreate())
df.write.format("delta").mode("overwrite").save("s3://lake/orders_delta/")
df = spark.read.format("delta").load("s3://lake/orders_delta/")
Delta è nato come cosa solo Databricks, è stato reso open source nel 2019, e nel 2026 è un progetto Linux Foundation con supporto first-class su Spark, Trino, Flink e i principali warehouse cloud. Non è più una storia di vendor lock-in.
Iceberg e Hudi: stessa idea, scommesse diverse
Delta non è l’unico formato di tabella transazionale. Apache Iceberg (originariamente da Netflix) e Apache Hudi (da Uber) attaccano lo stesso problema con scelte di design diverse.
- Iceberg ha uno strato di metadati più sofisticato (file manifest che descrivono liste di manifest che descrivono snapshot) che scala meglio su tabelle davvero enormi e supporta un’evoluzione di schema/partition più pulita. AWS, Snowflake, BigQuery hanno scommesso pesantemente sull’interoperabilità con Iceberg.
- Hudi si concentra su upsert in streaming a bassa latenza, con un tradeoff copy-on-write vs merge-on-read esposto agli utenti.
- Delta ha il tooling più ampio attorno (specialmente le ottimizzazioni di Databricks come Z-ORDER e liquid clustering) e il modello mentale più semplice.
Per i nuovi progetti nel 2026, la scelta è perlopiù tribale. I negozi Databricks usano Delta. I team AWS-centric tendono a Iceberg. I team con tanto streaming a volte scelgono Hudi. La buona notizia: tutti e tre risolvono gli stessi problemi di base, tutti e tre stanno sopra Parquet, e tutti e tre stanno convergendo verso feature set simili. Scegli quello che la tua piattaforma supporta meglio e non agonizzare.
Il panorama del 2026
Mettendo tutto insieme, ecco come ti consiglierei di pensare alla scelta del formato:
| Caso d’uso | Formato |
|---|---|
| Data lake analitico statico, prevalentemente append | Parquet |
| Negozio Hive con tabelle esistenti | ORC (non migrare) |
| Archivio Kafka in streaming | Avro, ricompattato a Parquet a valle |
| Lakehouse mutabile (UPDATE/DELETE/MERGE) | Delta (o Iceberg, o Hudi) |
| Time-travel per debugging richiesto | Delta (o Iceberg) |
| File piccolo una tantum per lettura umana | JSON / CSV |
Il trend macro è chiaro: i data lake Parquet puri stanno lentamente migrando a Delta o Iceberg, perché una volta che hai sperimentato scritture atomiche e MERGE INTO, non torni indietro. La migrazione non è banale (riorganizzare i layout di partition, riformare il team, prendersi il costo operativo di un transaction log), ma si ripaga la prima volta che un job alle 3 di notte muore a metà e la tabella è ancora consistente.
Prova questo
Scrivi lo stesso DataFrame come Parquet, ORC, Avro e Delta, poi esplora ognuno:
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = (SparkSession.builder
.appName("FormatsDemo")
.master("local[*]")
.config("spark.jars.packages",
"org.apache.spark:spark-avro_2.12:3.5.0,"
"io.delta:delta-spark_2.12:3.1.0")
.config("spark.sql.extensions",
"io.delta.sql.DeltaSparkSessionExtension")
.config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
.getOrCreate())
df = spark.range(0, 100_000).select(
F.col("id").alias("order_id"),
(F.col("id") % 50).alias("user_id"),
(F.rand() * 1000).alias("amount"),
F.lit("pending").alias("status"),
)
df.write.mode("overwrite").parquet("/tmp/demo/orders_parquet")
df.write.mode("overwrite").orc("/tmp/demo/orders_orc")
df.write.mode("overwrite").format("avro").save("/tmp/demo/orders_avro")
df.write.mode("overwrite").format("delta").save("/tmp/demo/orders_delta")
# MERGE INTO con Delta
from delta.tables import DeltaTable
updates = spark.range(0, 100).select(
F.col("id").alias("order_id"),
F.lit(0).alias("user_id"),
F.lit(99.99).alias("amount"),
F.lit("paid").alias("status"),
)
target = DeltaTable.forPath(spark, "/tmp/demo/orders_delta")
(target.alias("t")
.merge(updates.alias("u"), "t.order_id = u.order_id")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute())
# Quante righe hanno status = 'paid' adesso?
print(spark.read.format("delta").load("/tmp/demo/orders_delta")
.filter("status = 'paid'").count()) # 100
# Time travel: leggi la versione prima del merge
v0 = spark.read.format("delta").option("versionAsOf", 0).load("/tmp/demo/orders_delta")
print(v0.filter("status = 'paid'").count()) # 0
Apri /tmp/demo/orders_delta/_delta_log/ dopo aver eseguito tutto questo. Vedrai due file .json: 00000000000000000000.json per la scrittura iniziale, 00000000000000000001.json per il merge. Quello è il transaction log su cui Delta è costruito. Aprine uno e leggilo: è solo JSON che elenca file aggiunti e rimossi.
Nella prossima lezione lasciamo i formati di file e iniziamo con le sorgenti JDBC: tirare dati da Postgres, MySQL e SQL Server, incluso il trucco del partitionColumn che ti impedisce di fare accidentalmente un DDoS al tuo database di produzione.
Un paio di riferimenti in avanti: la lezione 47 copre i caveat dello storage cloud (costi di listing su S3, eventual consistency e il flag _SUCCESS) che mordono quando questi formati vivono su object storage invece di HDFS. La lezione 48 si addentra di più nell’evoluzione di schema: il tema Avro riceve un trattamento più completo lì, accanto a come Parquet e Delta gestiscono aggiunte e rimozioni di colonne.
Riferimenti: documentazione di Apache ORC (https://orc.apache.org/docs/), specifica di Apache Avro (https://avro.apache.org/docs/current/specification/) e documentazione di Delta Lake (https://docs.delta.io/latest/index.html). Consultati il 2026-05-01.