Se sei arrivato a PySpark da SQL, i join li conosci già. Il modello mentale si trasferisce quasi alla perfezione: INNER, LEFT, RIGHT, FULL OUTER, semi, anti, cross: stessi sette gusti, stesso significato combinatorio, stesso “ecco come trasformi due tabelle in una”. Quello che cambia in Spark è il costo. Un join in PostgreSQL su una tabella da un milione di righe sono qualche centinaio di millisecondi. Un join in Spark su una tabella da un miliardo di righe possono essere tre minuti o tre ore a seconda di come Spark decide di eseguirlo. Quella distinzione è di cosa parla il modulo 5.
Questa lezione copre la sintassi e la semantica: i sette tipi di join, i tre stili di condizione di join, e la trappola della colonna duplicata che morde tutti esattamente una volta. La lezione 27 copre i broadcast join (la via economica). La lezione 28 copre lo skew dei dati (la via lenta). La lezione 29 copre il salting (la via d’uscita dalla via lenta).
La forma canonica
Ogni join di Spark si riduce a:
result = df1.join(df2, on=<condition>, how=<type>)
on è cosa conta come match. how è cosa fare con le righe che non hanno match. Tutto qui. I default sono how="inner" e nessun on, che ti danno un cross join con un warning se ti dimentichi la condizione.
Un piccolo esempio di lavoro
Userò due DataFrame minuscoli in modo che l’output di ogni join sia abbastanza piccolo da leggere:
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = (SparkSession.builder
.appName("JoinsInPySpark")
.master("local[*]")
.getOrCreate())
customers = spark.createDataFrame(
[
(1, "Anna", "IT"),
(2, "Bjorn", "NL"),
(3, "Chiara","IT"),
(4, "Diego", "ES"), # never ordered
],
"customer_id INT, name STRING, country STRING",
)
orders = spark.createDataFrame(
[
(1001, 1, 59.00),
(1002, 1, 29.00),
(1003, 2, 149.00),
(1004, 3, 89.50),
(1005, 99, 12.00), # ghost customer — id 99 doesn't exist
],
"order_id INT, customer_id INT, total DOUBLE",
)
Quattro clienti (uno senza ordini), cinque ordini (uno per un cliente che non esiste). Tutti e sette i join qui sotto girano su questi.
Inner join: i match e solo i match
Il default. how="inner" è quello che ottieni se non passi how per niente.
customers.join(orders, on="customer_id", how="inner").show()
# +-----------+-------+-------+--------+------+
# |customer_id| name|country|order_id| total|
# +-----------+-------+-------+--------+------+
# | 1| Anna| IT| 1001| 59.00|
# | 1| Anna| IT| 1002| 29.00|
# | 2| Bjorn| NL| 1003|149.00|
# | 3| Chiara| IT| 1004| 89.50|
# +-----------+-------+-------+--------+------+
Diego (nessun ordine) non c’è. L’ordine 1005 (nessun cliente corrispondente) non c’è. L’inner join è “righe che fanno match su entrambi i lati, punto”.
Nota che Anna compare due volte: ha due ordini. I join moltiplicano. Una riga sulla sinistra può produrre N righe sulla destra se ci sono N match. Questa è di nuovo la cosa combinatoria sulle righe. Facile da scordarsi quando scrivi in fretta.
Left (left_outer) join: tieni tutto da sinistra
customers.join(orders, on="customer_id", how="left").show()
# +-----------+-------+-------+--------+------+
# |customer_id| name|country|order_id| total|
# +-----------+-------+-------+--------+------+
# | 1| Anna| IT| 1001| 59.00|
# | 1| Anna| IT| 1002| 29.00|
# | 2| Bjorn| NL| 1003|149.00|
# | 3| Chiara| IT| 1004| 89.50|
# | 4| Diego| ES| null| null|
# +-----------+-------+-------+--------+------+
Diego è tornato, con colonne null per tutto quello che viene da orders. how="left" e how="left_outer" sono alias: entrambi funzionano, entrambi vogliono dire la stessa cosa.
La domanda “clienti senza ordini” è un filtro banale sopra a questo:
(customers
.join(orders, on="customer_id", how="left")
.where(F.col("order_id").isNull())
.select("customer_id", "name", "country")
.show())
Right (right_outer) join: tieni tutto da destra
customers.join(orders, on="customer_id", how="right").show()
# +-----------+-------+-------+--------+------+
# |customer_id| name|country|order_id| total|
# +-----------+-------+-------+--------+------+
# | 1| Anna| IT| 1001| 59.00|
# | 1| Anna| IT| 1002| 29.00|
# | 2| Bjorn| NL| 1003|149.00|
# | 3| Chiara| IT| 1004| 89.50|
# | 99| null| null| 1005| 12.00|
# +-----------+-------+-------+--------+------+
Il cliente fantasma dell’ordine 1005 passa con null sul lato cliente. I right join esistono per completezza. Nella pratica nessuno li scrive: ribalti gli operandi e scrivi un left join. Un revisore di codice leggerà customers.join(orders, ..., how="left") più in fretta della versione con right join, sempre.
Full outer join: ci sono tutti
customers.join(orders, on="customer_id", how="full_outer").show()
# Diego appears (no orders), order 1005 appears (no customer),
# everyone else matches.
how="full", how="outer" e how="full_outer" sono tutti la stessa cosa. Utile per i report di riconciliazione: “cosa c’è nel sistema A che non c’è nel sistema B, cosa c’è in B che non c’è in A, e cosa fa match” tutto in una query. Lo uso più o meno con la stessa frequenza con cui uso FULL OUTER JOIN in SQL: raramente, ma quando ti serve niente altro va bene.
Left semi: un filtro, non un join
left_semi tiene le righe dal lato sinistro che hanno almeno un match sul lato destro. Non porta nessuna colonna dal lato destro. Non moltiplica le righe.
# Customers who have placed at least one order
customers.join(orders, on="customer_id", how="left_semi").show()
# +-----------+-------+-------+
# |customer_id| name|country|
# +-----------+-------+-------+
# | 1| Anna| IT|
# | 2| Bjorn| NL|
# | 3| Chiara| IT|
# +-----------+-------+-------+
Anna compare una volta sola anche se ha due ordini. Il semi join è di sola appartenenza: una riga per match a sinistra, niente duplicazione. Questo è l’equivalente Spark di WHERE EXISTS (...) in SQL, ed è quasi sempre quello che vuoi quando stai usando un join puramente come filtro.
Se hai mai scritto df.join(other, "key", "inner").select(df["*"]).distinct() in codice di produzione: smetti. left_semi è più veloce, più pulito, e non fa esplodere accidentalmente le righe quando il lato destro ha duplicati.
Left anti: l’inverso
left_anti tiene le righe dal lato sinistro che non hanno match sul lato destro.
# Customers with no orders
customers.join(orders, on="customer_id", how="left_anti").show()
# +-----------+-----+-------+
# |customer_id| name|country|
# +-----------+-----+-------+
# | 4|Diego| ES|
# +-----------+-----+-------+
Questo è WHERE NOT EXISTS (...) da SQL. È il modo più pulito per rispondere a ogni domanda del tipo “cose a sinistra senza cose a destra”: clienti senza ordini, prodotti che non sono mai stati venduti, utenti che non hanno mai fatto login. più pulito del pattern left join + where col is null, e l’optimizer di Spark lo tratta in modo più diretto.
Cross join: il prodotto cartesiano
Ogni riga a sinistra accoppiata con ogni riga a destra. 4 clienti per 5 ordini fanno 20 righe, nessuna delle quali significa qualcosa.
# Spark refuses to run this unless you explicitly ask:
customers.crossJoin(orders).count() # 20
Il motivo per cui crossJoin ha un metodo a sé (invece di how="cross") è che un prodotto cartesiano accidentale su dati veri può essere catastrofico: un milione di righe da ogni lato diventano un trilione. Spark vuole che tu scriva crossJoin proprio perché non ci si possa inciampare dentro.
Quando è utile un cross join? Per costruire griglie dimensionali (“ogni prodotto x ogni giorno degli ultimi 90 giorni, così il LEFT JOIN alle vendite non ha buchi temporali”), per generare dati di test combinatori, e per casi rari di reportistica. Nel lavoro di tutti i giorni, quasi mai.
Tre modi per scrivere la condizione di join
Il parametro on accetta tre stili, e li vedrai tutti e tre nel codice di produzione:
# 1. String — works when both sides have the column with the same name
customers.join(orders, on="customer_id")
# 2. List of strings — same idea, multiple equality keys
events.join(sessions, on=["user_id", "session_date"])
# 3. Explicit column expression — most flexible, required if names differ
customers.join(orders, on=customers["customer_id"] == orders["customer_id"])
I primi due stili hanno un vantaggio nascosto: Spark elimina la colonna di join duplicata dal risultato. Lo stile con espressione no. Guarda cosa succede:
result = customers.join(
orders,
on=customers["customer_id"] == orders["customer_id"],
how="inner",
)
result.columns
# ['customer_id', 'name', 'country', 'order_id', 'customer_id', 'total']
# ^^ two columns named customer_id ^^
Due colonne chiamate customer_id, entrambe legali, entrambe ambigue da referenziare. Prova result.select("customer_id") e Spark ti tira un AMBIGUOUS_REFERENCE. Benvenuto nella trappola della colonna duplicata.
La trappola della colonna duplicata e come evitarla
Tre rimedi, in ordine di preferenza.
Rimedio 1: usa la forma stringa o lista quando puoi. È la più pulita e Spark deduplica per te:
customers.join(orders, on="customer_id").select("customer_id", "name", "total")
Rimedio 2: aliasa i DataFrame prima del join. Adesso non c’è ambiguità perché le colonne sono qualificate:
c = customers.alias("c")
o = orders.alias("o")
(c.join(o, F.col("c.customer_id") == F.col("o.customer_id"))
.select("c.customer_id", "c.name", "o.total")
.show())
Rimedio 3: rinomina o butta via uno dei due lati prima del join. Meno elegante, a volte pragmatico:
orders_renamed = orders.withColumnRenamed("customer_id", "cust_id")
customers.join(orders_renamed, customers["customer_id"] == orders_renamed["cust_id"])
Scegli una convenzione e attieniti a quella in tutta la codebase. Mescolare gli stili è quello che crea i bug.
Condizioni oltre la semplice uguaglianza
L’espressione di on è solo un booleano: qualunque cosa che ritorna true/false su una coppia di righe è valida. I range join, i join multi-condizione, i join con disuguaglianza funzionano tutti:
# Range join: events that happened during a user's session
sessions.join(
events,
on=(
(sessions["user_id"] == events["user_id"]) &
(events["timestamp"] >= sessions["start"]) &
(events["timestamp"] < sessions["end"])
),
how="inner",
)
Utile, ma occhio: solo i predicati di uguaglianza possono usare la via veloce dell’hash join di Spark. Aggiungi una disuguaglianza e Spark ripiega su un sort-merge o, peggio, su un broadcast nested loop. Il job funziona lo stesso, è solo più lento. Vedremo questo nell’output di .explain() nella lezione 27.
Qualche pattern pratico
Tre pattern che vale la pena conoscere perché saltano fuori di continuo:
Arricchimento. Una tabella di eventi larga unita a una tabella dimensione sottile: ogni evento ha bisogno di sapere il nome del paese, non solo il codice del paese. Inner join sulla chiave primaria della dimensione, proietti le colonne che ti servono davvero:
events.join(
dim_country.select("country_code", "country_name"),
on="country_code",
how="left",
)
Nota la select sul lato destro. Limita le colonne che Spark deve trasportare attraverso lo shuffle. Abitudini come questa contano alla scala: ogni colonna che ti porti dietro sono byte sul cavo.
Deduplicazione tramite anti-join. Hai una tabella “cose processate ieri” e “cose da processare oggi”. Vuoi oggi meno ieri:
today.join(yesterday.select("id"), on="id", how="left_anti")
più pulito dell’equivalente WHERE NOT IN, ed è lo strumento giusto. NOT IN con null sul lato destro dà risultati sorprendenti (qualunque null fa tornare zero righe); left_anti non ha quel piede in fallo.
Controllo di esistenza tramite semi. Un utente è “attivo” se compare sia in logins_30d che in purchases_30d:
active = (logins_30d.select("user_id").distinct()
.join(purchases_30d.select("user_id"), on="user_id", how="left_semi"))
Due distinct, un semi join. Nessuna moltiplicazione accidentale di righe, nessun null di cui preoccuparsi.
Cosa arriva dopo
Questi sono i sette join. La semantica è di forma SQL e non controversa. La domanda interessante, quella che decide se il tuo job gira in 30 secondi o in 30 minuti, è come Spark esegue fisicamente il join. Ci sono tre strategie principali (broadcast hash join, sort-merge join, shuffle hash join), e Spark ne sceglie una in base alla dimensione di ciascun lato, al tipo di join e alla configurazione.
La lezione 27 si tuffa nei broadcast join: quando un lato è abbastanza piccolo da poter essere mandato ovunque, salti del tutto lo shuffle e i join diventano economici. La lezione 28 copre cosa succede quando un lato ha una chiave con 100 volte le righe degli altri: il temuto problema dello skew. La lezione 29 copre il salting, il rimedio standard.
Per ora, il modello mentale giusto è: scrivi il join nel modo in cui scriveresti SQL, scegli il how giusto, e schivi la trappola della colonna duplicata. La performance arriva dopo.
Riferimenti: documentazione Apache Spark SQL sui tipi di join e API DataFrame join; post sul blog di engineering di Databricks sulle strategie di join. Recuperato il 2026-05-01.