🔗 Le Query su Più Tabelle
Il JOIN unisce i dati che stanno in due o più tabelle; il LEFT JOIN, in più, conserva le righe che nell'altra tabella non trovano corrispondenza.
🗄️ 1. Il Database di Esempio
Questa guida usa cinque tabelle, tutte con dati veri e completi. Sono le
stesse tabelle della guida sulle subquery, con una differenza:
ordini ha in più la colonna prodotto_id. Senza quella colonna non
potremmo unire gli ordini ai prodotti, e metà di questa pagina non avrebbe niente da insegnare.
Il dataset contiene apposta tre casi "sporchi", cioè righe che non trovano la
corrispondenza nell'altra tabella. Sono quelli che rendono interessante il LEFT JOIN:
Gallo Pietro non ha mai ordinato, la Webcam HD non è mai stata
ordinata. Il terzo caso — un reparto senza dipendenti — qui non c'è: tutti e quattro i reparti
hanno almeno un dipendente. Lo incontrerai nella finestra esercizi, che genera ogni volta un
dataset diverso.
Modello logico
In ogni tabella la chiave primaria è la prima colonna e le chiavi esterne le ultime: sono le colonne che collegano le tabelle, e in un JOIN sai già dove guardare.
Nota che clienti e dipendenti tengono
cognome e nome in due attributi separati: sono due informazioni
diverse, e un attributo solo non permetterebbe né di cercare per cognome né di ordinare
l'elenco telefonico. Il nome completo, quando serve, si ottiene scrivendolo:
CONCAT(cognome, ' ', nome). Nei modelli lo scriveremo come
cognome nome.
prodotti(id(PK), nome, categoria, prezzo)
ordini(id(PK), importo, data_ordine, cliente_id(FK), prodotto_id(FK))
reparti(id(PK), nome, sede, budget)
dipendenti(id(PK), cognome, nome, stipendio, data_assunzione, reparto_id(FK))
I dati
Otto clienti, otto prodotti, dieci ordini, quattro reparti e otto dipendenti. Sono pochi, e per questo li puoi controllare a occhio: ogni risultato delle sezioni seguenti si può verificare riga per riga sulle tabelle qui sotto.
Chi compra, e da dove
| id | cognome | nome | citta |
|---|---|---|---|
| 1 | Rossi | Mario | Milano |
| 2 | Bianchi | Giulia | Roma |
| 3 | Ferrari | Luca | Milano |
| 4 | Greco | Sara | Napoli |
| 5 | Conti | Marco | Torino |
| 6 | Bruno | Elena | Roma |
| 7 | De Luca | Chiara | Bologna |
| 8 | Gallo | Pietro | Milano |
L'id 8, Gallo Pietro, non compare in nessun ordine: è il
cliente che useremo per spiegare il LEFT JOIN.
Gli ordini
| id | cliente_id | prodotto_id | importo | data_ordine |
|---|---|---|---|---|
| 1 | 1 | 1 | 329 | 2026-01-12 |
| 2 | 1 | 2 | 79 | 2026-01-28 |
| 3 | 2 | 3 | 89 | 2026-02-03 |
| 4 | 3 | 1 | 329 | 2026-02-11 |
| 5 | 4 | 4 | 45 | 2026-02-19 |
| 6 | 4 | 5 | 149 | 2026-03-02 |
| 7 | 5 | 6 | 199 | 2026-03-08 |
| 8 | 6 | 7 | 35 | 2026-03-15 |
| 9 | 7 | 2 | 79 | 2026-03-21 |
| 10 | 1 | 5 | 149 | 2026-04-02 |
Rossi Mario (id 1) ha tre ordini, Greco Sara (id 4) ne ha due. La colonna
importo ripete il prezzo del prodotto ordinato: è una scorciatoia didattica, così i
totali si leggono senza un altro JOIN.
Cosa si vende
| id | nome | categoria | prezzo |
|---|---|---|---|
| 1 | Monitor 27 pollici | Elettronica | 329 |
| 2 | Tastiera meccanica | Accessori | 79 |
| 3 | Cuffie senza fili | Elettronica | 89 |
| 4 | Lampada da tavolo | Arredo | 45 |
| 5 | Sedia da ufficio | Arredo | 149 |
| 6 | Stampante laser | Elettronica | 199 |
| 7 | Mouse ergonomico | Accessori | 35 |
| 8 | Webcam HD | Elettronica | 129 |
Il prodotto 8, la Webcam HD, non compare in nessun ordine: è il caso speculare a Gallo Pietro.
Chi lavora dove
| id | nome | sede | budget |
|---|---|---|---|
| 1 | Produzione | Milano | 1200000 |
| 2 | Vendite | Milano | 900000 |
| 3 | Amministrazione | Torino | 600000 |
| 4 | Ricerca | Bologna | 750000 |
| id | cognome | nome | reparto_id | stipendio | data_assunzione |
|---|---|---|---|---|---|
| 1 | Ferrari | Anna | 1 | 42000 | 2019-09-02 |
| 2 | Greco | Marco | 1 | 38000 | 2021-03-15 |
| 3 | Rizzo | Andrea | 1 | 33000 | 2023-01-09 |
| 4 | Bianchi | Sara | 2 | 55000 | 2018-05-21 |
| 5 | Conti | Luca | 2 | 47000 | 2020-11-03 |
| 6 | Ricci | Elena | 3 | 36000 | 2022-07-18 |
| 7 | Marino | Paolo | 3 | 41000 | 2019-02-11 |
| 8 | Gallo | Giulia | 4 | 64000 | 2017-10-30 |
Tutti gli otto dipendenti hanno un reparto: Produzione ne ha tre, Vendite e Amministrazione due, Ricerca uno.
❓ 2. Perché Servono Più Tabelle
2.1 La domanda che non si può fare su una tabella sola
Proviamo a rispondere a una domanda fra le più frequenti negli esercizi di SQL: quanto ha ordinato Rossi Mario, e quando?
Cerchiamo Rossi Mario in clienti:
| id | nome | citta | |
|---|---|---|---|
| 1 | Rossi | Mario | Milano |
Ora sappiamo che Rossi Mario è il cliente numero 1. Ma nella tabella
clienti non c'è nessuna traccia dei suoi ordini: la tabella ordini li
elenca, però senza il nome del cliente, solo con il suo numero. Nessuna delle due tabelle, da
sola, sa rispondere.
🎯 L'IDEA CHIAVE
Il JOIN mette in fila le righe che si corrispondono. Prende la riga di
clienti con id = 1, le righe di ordini con
cliente_id = 1, e le affianca in un'unica riga del risultato: da una parte
Rossi Mario, dall'altra i suoi tre ordini.
2.2 Perché non mettere tutto in una tabella sola?
Sarebbe più semplice, almeno all'inizio. Ma se copiassimo il nome del cliente dentro ogni riga
di ordini, avremmo tre copie di "Rossi Mario". E se Rossi si trasferisse a Torino,
dovremmo aggiornare la città in tutte le sue righe: se ne dimenticassimo una, il
database si contraddirebbe da solo.
Questo problema ha un nome preciso, e l'abbiamo già visto: è la normalizzazione. Le tabelle sono separate perché ogni dato sia scritto una volta sola; il JOIN è il modo in cui le rimettiamo insieme quando serve.
Se vuoi rivedere il ragionamento, la guida sulle forme normali spiega perché una tabella sola non va bene e come si arriva a ottenerne cinque.
2.3 Ogni query legge da una sola tabella di righe
Questa è la regola che spiega perché il JOIN esiste, e vale la pena leggerla due volte.
Il computer, quando esegue una SELECT, non legge mai da due tabelle
insieme: legge sempre da una sola tabella di righe — un solo insieme di righe, una
colonna per attributo. Quella tabella può essere
una tabella vera del database, oppure — ed è il caso che ci interessa — il risultato
temporaneo di una combinazione.
Da qui discendono tre conseguenze pratiche:
SELECTsceglie colonne di quella tabella di righe;WHEREsceglie righe di quella tabella di righe;GROUP BYraggruppa righe di quella tabella di righe.
Se il dato che ti serve sta in due tabelle diverse, nessuna di queste tre clausole può prenderlo: prima devi costruire la tabella di righe che le mette insieme, e poi lavorarci. Il JOIN è esattamente l'operazione che la costruisce.
🎯 L'ORDINE DEI DUE PASSAGGI
1. Combino le righe delle tabelle che mi servono, ottenendo una tabella
in cui ogni riga è una combinazione di righe delle tabelle di partenza.
2. Su quella tabella faccio tutto il resto: scelgo le colonne, filtro le
righe, raggruppo, ordino.
Il SELECT che scrivi descrive il passo 2, ma può nominare le colonne di
entrambe le tabelle solo perché il passo 1 è già avvenuto.
2.4 La tabella combinata, riga per riga
Vediamo il passo 1 su due tabelle. Vogliamo quanto ha speso ogni cliente: il nome sta
in clienti, gli importi in ordini. Senza combinare niente, il computer
ha a disposizione 8 righe da una parte e 10 dall'altra, e nessuna delle due ha sia il nome sia
l'importo.
La combinazione parte da tutte le coppie possibili: ogni riga di
clienti con ogni riga di ordini, cioè 8 × 10 = 80 righe.
Fra queste ce ne sono di giuste e di sbagliate, mescolate. Ecco le prime cinque:
| cliente | ordine | cliente_id | giusta? |
|---|---|---|---|
| Rossi Mario | 1 | 1 | ✅ sì |
| Rossi Mario | 2 | 1 | ✅ sì |
| Rossi Mario | 3 | 2 | ❌ no: l'ordine 3 è di un altro cliente |
| Rossi Mario | 4 | 3 | ❌ no: l'ordine 4 è di un altro cliente |
| Rossi Mario | 5 | 4 | ❌ no: l'ordine 5 è di un altro cliente |
| … e altre 75 combinazioni | |||
Il JOIN prende quelle 80 righe e tiene solo le combinazioni in cui la condizione di
ON è vera, cioè quelle in cui ordini.cliente_id è uguale a
clienti.id. Restano 10 righe: tante quante sono gli ordini, perché
ogni ordine ha trovato il suo cliente. Questa è la tabella combinata su cui lavorerà tutto il
resto della query:
| cliente | ordine | cliente_id | importo |
|---|---|---|---|
| Rossi Mario | 1 | 1 | 329 |
| Rossi Mario | 2 | 1 | 79 |
| Rossi Mario | 10 | 1 | 149 |
| Bianchi Giulia | 3 | 2 | 89 |
| Ferrari Luca | 4 | 3 | 329 |
| Greco Sara | 5 | 4 | 45 |
| Greco Sara | 6 | 4 | 149 |
| Conti Marco | 7 | 5 | 199 |
| Bruno Elena | 8 | 6 | 35 |
| De Luca Chiara | 9 | 7 | 79 |
Le colonne cliente e cliente_id dicono la stessa
cosa: la seconda è la chiave esterna che ha reso possibile la combinazione, la prima è il nome che
siamo andati a prendere nell'altra tabella.
Ora la domanda "quanto ha speso ogni cliente" è una normalissima domanda su
una tabella sola: raggruppo per cliente e sommo importo. Il
GROUP BY non sa niente di JOIN, e non deve saperlo: lavora sulle righe che ha.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome
| cliente | totale |
|---|---|
| Rossi Mario | 557 |
| Ferrari Luca | 329 |
| Conti Marco | 199 |
| Greco Sara | 194 |
| Bianchi Giulia | 89 |
| De Luca Chiara | 79 |
| Bruno Elena | 35 |
Controlla il primo totale sulle righe della tabella combinata: Rossi Mario compare tre volte (ordini 1, 2 e 10), e 329 + 79 + 149 = 557. Il totale non è un calcolo "fra due tabelle": è la somma di tre importi, uno per ognuna delle sue tre righe nella tabella combinata.
📌 Da ricordare
Il JOIN non è una clausola che "fa vedere" due tabelle al resto della
query. È un'operazione che produce una tabella nuova, e quella tabella è
l'unica cosa che SELECT, WHERE, GROUP BY e
ORDER BY vedono. Per questo ogni tabella che aggiungi con un JOIN
deve agganciarsi a una tabella già presente: la combinazione si costruisce un pezzo alla
volta, come vedremo nella sezione 6.
2.5 Come si scrive un JOIN
FROM tabella_1
JOIN tabella_2 ON tabella_1.chiave = tabella_2.chiave_esterna
Prima di scrivere bisogna decidere quali tabelle servono. Non è una scelta libera: dipende da quali dati chiede la traccia.
- Quali dati devo mostrare? Ogni colonna che compare nella richiesta deve
esistere in una tabella. Se la traccia chiede "il nome del cliente e l'importo dell'ordine",
servono
clienti(per il nome) eordini(per l'importo). - Il numero minimo di tabelle: si prendono solo quelle che contengono almeno un dato richiesto, o che servono a collegare due tabelle altrimenti scollegate.
- Come si collegano: fra due tabelle serve un percorso, cioè una chiave
esterna che le metta in relazione. Se il percorso non è diretto, serve una tabella in più che
faccia da ponte: è il caso della sezione 6, dove
ordinicollegaclientieprodotti.
Solo dopo si scrive il JOIN: le tabelle scelte vanno combinate in una tabella
sola, con la condizione di ON che dice quali righe si corrispondono.
📌 La differenza che conta
L'ordine in cui si scrive il JOIN — quale tabella in FROM e quale
dopo JOIN — non cambia il risultato: le due tabelle si possono scambiare,
basta scambiare di conseguenza le due colonne in ON. Quello che cambia il
risultato è quali tabelle si sono scelte, e se sono quelle che servono
davvero.
2.6 Quando il JOIN non serve
La chiave esterna è un attributo della tabella che la contiene. In
ordini c'è cliente_id, in dipendenti c'è
reparto_id: quei numeri si leggono già da lì, senza chiedere niente all'altra
tabella.
Richiesta: per ogni ordine, il numero del cliente che lo ha fatto. Quel numero è
ordini.cliente_id: è già in ordini.
✅ BASTA UNA TABELLA
SELECT id AS ordine, cliente_id
FROM ordini
10 righe. Il numero del cliente è un dato di ordini, come l'importo o
la data.
❌ JOIN INUTILE
SELECT o.id AS ordine, c.id AS cliente_id
FROM ordini o
JOIN clienti c ON c.id = o.cliente_id
Le stesse 10 righe, gli stessi valori: c.id è per forza uguale a
o.cliente_id, perché è la condizione di ON a dirlo. Il JOIN non
ha aggiunto nessuna informazione.
Il JOIN serve quando dall'altra tabella si vuole un dato che nella prima non c'è: il nome del cliente, la sua città, il prezzo del prodotto. Se serve solo il numero, il numero è già lì.
| dato richiesto | dove sta | serve il JOIN? |
|---|---|---|
| il numero del cliente | ordini.cliente_id | no |
| l'importo, la data | ordini.importo, ordini.data_ordine | no |
| il nome del cliente | clienti.nome | sì |
| la città del cliente | clienti.citta | sì |
E non è solo una questione di eleganza: il JOIN inutile può cambiare il
risultato. Se una chiave esterna non trova la riga che dovrebbe indicare — succede
quando il vincolo non è applicato, o quando il valore è NULL — il JOIN
scarta quella riga, mentre ordini da sola la mostra:
| id | cliente_id | con FROM ordini | con il JOIN |
|---|---|---|---|
| 1 | 1 | c'è | c'è |
| … | … | … | … |
| 11 | NULL | c'è | sparisce |
🎯 LA DOMANDA DA FARSI
Il dato che mi serve è già nella tabella da cui parto? Se sì, il JOIN non serve. La chiave esterna non è un "collegamento" da attivare: è un attributo come gli altri, che si può leggere direttamente.
Si aggancia l'altra tabella solo quando serve un suo dato — il nome, la città, il prezzo — che nella prima non esiste.
🔗 3. JOIN su Due Tabelle
3.1 Il primo JOIN
Nella sezione 2.4 abbiamo costruito la tabella combinata e l'abbiamo usata per calcolare i totali. Qui rifacciamo lo stesso percorso guardando le righe una per una, per capire come nasce ogni riga di quella tabella.
Agganciamo ordini a clienti. La condizione è semplice: ogni riga di
ordini deve trovare la riga di clienti che ha lo stesso
id del proprio cliente_id. Le due colonne da confrontare si chiamano in
modo diverso — id e cliente_id — ed è proprio il caso più comune.
Per capire cosa fa il JOIN basta seguire un ordine solo, il numero 4, dalla
sua riga nella tabella ordini fino alla riga corrispondente del risultato. Sono le
stesse righe delle tabelle della sezione 1, lette in ordine diverso.
① Partiamo dalla riga di ordini che vogliamo spiegare
| id | cliente_id | prodotto_id | importo | data_ordine |
|---|---|---|---|---|
| 4 | 3 | 1 | 329 | 2026-02-11 |
L'ordine 4 è stato fatto dal cliente 3. Da solo, però, quel
3 non dice chi è: per saperlo bisogna cercare nella tabella
clienti.
cerco in clienti la riga con id = 3
② La riga che corrisponde in clienti
| id | nome | citta |
|---|---|---|
| 3 | Ferrari Luca | Milano |
La condizione o.cliente_id = c.id è soddisfatta: 3 = 3.
Il cliente dell'ordine 4 è Ferrari Luca.
le due righe diventano una sola
③ La riga combinazione, con tutte le colonne delle due tabelle
Il JOIN non sceglie le colonne: mette in fila tutte le colonne della prima tabella e tutte quelle della seconda. È una riga larga: si scorre con le frecce ← → dopo averci cliccato sopra.
| da clienti | da ordini | |||||||
|---|---|---|---|---|---|---|---|---|
| clienti.id | clienti.cognome | clienti.nome | clienti.citta | ordini.id | ordini.cliente_id | ordini.prodotto_id | ordini.importo | ordini.data_ordine |
| 3 | Ferrari | Luca | Milano | 4 | 3 | 1 | 329 | 2026-02-11 |
◀ ▶ per scorrere la riga · Home e End per gli estremi
Le due celle evidenziate sono la coppia di ON:
clienti.id e ordini.cliente_id, lo stesso valore 3.
Sono due colonne distinte che in questa riga portano lo stesso numero — è la condizione che
ha reso possibile la combinazione.
Dalla riga combinazione il SELECT estrae quello che gli
serve: nome e importo vengono da colonne diverse della stessa
riga, ed è per questo che una query su più tabelle si può scrivere.
| nome | importo | data_ordine |
|---|---|---|
| Ferrari Luca | 329 | 2026-02-11 |
Il nome viene da clienti ed è stato composto
con CONCAT a partire da cognome e nome;
importo e data_ordine vengono da ordini, prese così
come sono.
La stessa cosa succede per tutti e dieci gli ordini: il JOIN li prende uno
per uno e a ciascuno aggancia il suo cliente. Per questo Rossi Mario compare tre
volte (ha tre ordini), Greco Sara due, e tutti gli altri una sola. Resta fuori
una riga sola, quella di Gallo Pietro: nessun ordine ha
cliente_id = 8. È il caso che analizziamo nella sezione 3.2.
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo, o.data_ordine
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
| nome | importo | data_ordine |
|---|---|---|
| Rossi Mario | 329 | 2026-01-12 |
| Rossi Mario | 79 | 2026-01-28 |
| Rossi Mario | 149 | 2026-04-02 |
| Bianchi Giulia | 89 | 2026-02-03 |
| Ferrari Luca | 329 | 2026-02-11 |
| Greco Sara | 45 | 2026-02-19 |
| Greco Sara | 149 | 2026-03-02 |
| Conti Marco | 199 | 2026-03-08 |
| Bruno Elena | 35 | 2026-03-15 |
| De Luca Chiara | 79 | 2026-03-21 |
Dieci righe, tante quante sono gli ordini: ogni ordine ha trovato il suo cliente, quindi nessuno è andato perso. Il nome del cliente si ripete — Rossi Mario compare tre volte — perché compare una volta per ordine, non una volta per cliente. È la quinta riga del risultato quella che abbiamo costruito qui sopra: Ferrari Luca · 329 · 2026-02-11.
3.2 Chi non ha ordini sparisce
Se chiediamo solo i nomi, il risultato ha ancora dieci righe ma solo sette nomi diversi:
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
| nome |
|---|
| Rossi Mario |
| Rossi Mario |
| Rossi Mario |
| Bianchi Giulia |
| Ferrari Luca |
| Greco Sara |
| Greco Sara |
| Conti Marco |
| Bruno Elena |
| De Luca Chiara |
Manca Gallo Pietro: la sua riga di clienti non ha trovato nessun
ordine che la citasse, quindi è stata scartata, e con lei tutti i suoi dati. Una riga
entra nel risultato solo se trova almeno una corrispondenza nell'altra tabella. Non è un
errore: è il comportamento normale del JOIN.
📌 Da ricordare
JOIN e INNER JOIN sono la stessa cosa: JOIN è la
forma breve, INNER JOIN quella per esteso. Nelle prossime sezioni vedremo cosa
cambia con LEFT JOIN.
3.3 I nomi di colonna uguali
Quando due tabelle hanno una colonna con lo stesso nome, per dire quale intendiamo dobbiamo
scrivere il nome della tabella (o il suo alias) prima del punto. Fra
clienti e ordini l'unica colonna omonima è id; appena si
aggiunge una terza tabella i nomi in comune aumentano.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, p.nome AS prodotto
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti p ON p.id = o.prodotto_id
Qui nome esiste sia in clienti sia in prodotti: senza il
prefisso c. o p. MariaDB non saprebbe quale delle due mostrare. Nella
sezione 6 questo JOIN lo useremo per intero.
3.4 Errore: la colonna ambigua
❌ SBAGLIATO
SELECT id, nome
FROM clienti JOIN ordini ON ordini.cliente_id = clienti.id;
MariaDB si rifiuta di eseguire la query: Column 'id' in field list is
ambiguous. Esiste in entrambe le tabelle e non sa quale mostrare.
✅ CORRETTO
SELECT clienti.id, CONCAT(clienti.cognome, ' ', clienti.nome) AS nome
FROM clienti JOIN ordini ON ordini.cliente_id = clienti.id;
Basta anteporre il nome della tabella alla colonna ambigua. Fra poco vedremo la forma più comoda: l'alias.
3.5 Un esempio in più: il JOIN con un filtro
Il JOIN decide quali righe si abbinano; il WHERE decide quali righe
del risultato teniamo. Sono due lavori diversi e si possono usare insieme. Qui vogliamo solo
gli ordini dei clienti di Milano:
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo, o.data_ordine
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
WHERE c.citta = 'Milano'
| nome | importo | data_ordine |
|---|---|---|
| Rossi Mario | 79 | 2026-01-28 |
| Rossi Mario | 149 | 2026-04-02 |
| Rossi Mario | 329 | 2026-01-12 |
| Ferrari Luca | 329 | 2026-02-11 |
Quattro righe invece di dieci: il JOIN aveva già messo insieme tutti gli ordini con il loro
cliente, poi il WHERE ha scartato quelli dei clienti di altre città. Nota che
Gallo Pietro non c'è: abita a Milano, ma il JOIN lo aveva già
eliminato prima che il WHERE entrasse in scena.
Un'ultima cosa: l'ordine di queste quattro righe non è garantito. Il database può
restituirle come vuole; per decidere l'ordine serve ORDER BY, che vediamo nella
sezione 8.
👈 4. LEFT JOIN: le Righe che Mancano
4.1 Cosa cambia
Nella sezione 3 abbiamo visto che il JOIN butta via chi non ha corrispondenza. Ma
spesso le informazioni che ci servono sono proprio quelle: quali clienti non hanno ancora
ordinato, quali prodotti non sono mai stati venduti. Il LEFT JOIN nasce
per questo. La differenza sta in una sola parola:
JOIN (cioè INNER JOIN)
Tiene solo le righe che trovano una corrispondenza nell'altra tabella.
LEFT JOIN
Tiene tutte le righe della prima tabella. Quelle senza corrispondenza
restano, con NULL al posto dei dati che mancano.
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo, o.data_ordine
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
| nome | importo | data_ordine |
|---|---|---|
| Rossi Mario | 329 | 2026-01-12 |
| Rossi Mario | 79 | 2026-01-28 |
| Rossi Mario | 149 | 2026-04-02 |
| Bianchi Giulia | 89 | 2026-02-03 |
| Ferrari Luca | 329 | 2026-02-11 |
| Greco Sara | 45 | 2026-02-19 |
| Greco Sara | 149 | 2026-03-02 |
| Conti Marco | 199 | 2026-03-08 |
| Bruno Elena | 35 | 2026-03-15 |
| De Luca Chiara | 79 | 2026-03-21 |
| Gallo Pietro | NULL | NULL |
Sono 11 righe invece di 10. L'ultima è Gallo Pietro, che prima
era sparito: c'è, ma con NULL al posto di importo e
data_ordine. Non ha ordinato niente, quindi di importi non ce ne sono. Tutti gli
altri hanno esattamente le stesse righe di prima.
🎯 IL NULL TI DICE CHE NON C'ERA
Quel NULL non è un errore: è l'informazione più utile di tutta la query.
Significa "questo cliente esiste, ma non ha nessun ordine". È esattamente quello che stavamo
cercando di sapere.
4.2 Il trucco: LEFT JOIN + IS NULL
Ora che il LEFT JOIN ci restituisce anche chi non ha ordinato, basta chiedere
esplicitamente le righe in cui l'altra tabella non ha dato niente:
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, c.citta
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
WHERE o.id IS NULL
| nome | citta |
|---|---|
| Gallo Pietro | Milano |
Il filtro è o.id IS NULL: l'id di un ordine non è mai vuoto, quindi
se è vuoto vuol dire che quell'ordine non esiste. Attenzione: si scrive IS
NULL, non = NULL. Con il segno uguale non si ottiene niente, perché
un valore ignoto non è uguale nemmeno a se stesso.
📌 Il ricettario dei due passaggi
LEFT JOIN + IS NULL è il modo per chiedere "chi non
ha". Lo rivedremo nella sezione 9, dove lo confrontiamo con NOT EXISTS, e
nella sezione 12 c'è l'esercizio sui prodotti mai ordinati.
4.3 RIGHT JOIN: la stessa cosa, al contrario
RIGHT JOIN esiste ed è il simmetrico del LEFT JOIN: tiene tutte le
righe della seconda tabella. Per ottenere lo stesso risultato basta scambiare
l'ordine delle due tabelle e mettere RIGHT al posto di LEFT.
✅ LEFT JOIN (quello che scriveremo)
FROM clienti c LEFT JOIN ordini o ON o.cliente_id = c.id
- Partiamo dai clienti: sono loro che ci interessano.
- Tutti i clienti restano, anche chi non ha ordinato.
⚠️ RIGHT JOIN (lo stesso, capovolto)
FROM ordini o RIGHT JOIN clienti c ON o.cliente_id = c.id
- Partiamo dagli ordini, ma il risultato parla di clienti: si legge peggio.
- Stesso risultato, lettura più faticosa.
In pratica RIGHT JOIN si usa di rado: mettere per prima la tabella di cui vogliamo
tutte le righe si legge molto meglio. Per questo LEFT JOIN è diventata
l'abitudine standard per tenere le righe che mancano.
4.4 Un esempio in più: un filtro che il LEFT JOIN lo lascia intatto
Nella sezione 11 vedremo che un filtro messo male può cancellare un LEFT JOIN.
Ma non tutti i filtri lo fanno: dipende da quale tabella filtrano. Qui chiediamo
tutti i prodotti Accessori e, se li hanno, i loro ordini:
SELECT p.nome AS prodotto, o.id AS ordine
FROM prodotti p
LEFT JOIN ordini o ON o.prodotto_id = p.id
WHERE p.categoria = 'Accessori'
| prodotto | ordine |
|---|---|
| Tastiera meccanica | 2 |
| Tastiera meccanica | 9 |
| Mouse ergonomico | 8 |
Il filtro parla di p.categoria, cioè di una colonna della prima
tabella: il LEFT JOIN continua a funzionare, e un prodotto Accessori senza ordini
comparirebbe lo stesso con NULL. Il confronto è con la sezione 11.1, dove lo stesso
filtro scritto su una colonna della seconda tabella fa sparire proprio le righe che il
LEFT JOIN aveva salvato.
🏷️ 5. Gli Alias e il Self-Join
5.1 L'alias: un nome breve per la tabella
Quando la stessa colonna nome compare in più tabelle — clienti,
prodotti, reparti, dipendenti — scrivere
clienti.nome, prodotti.nome, reparti.nome diventa scomodo.
Si assegna alla tabella un
alias: un nome breve che si scrive dopo il nome della tabella e vale per tutto il
resto della query.
SELECT CONCAT(d.cognome, ' ', d.nome) AS dipendente, r.nome AS reparto, d.stipendio
FROM dipendenti d
JOIN reparti r ON r.id = d.reparto_id
| dipendente | reparto | stipendio |
|---|---|---|
| Ferrari Anna | Produzione | 42000 |
| Greco Marco | Produzione | 38000 |
| Rizzo Andrea | Produzione | 33000 |
| Bianchi Sara | Vendite | 55000 |
| Conti Luca | Vendite | 47000 |
| Ricci Elena | Amministrazione | 36000 |
| Marino Paolo | Amministrazione | 41000 |
| Gallo Giulia | Ricerca | 64000 |
Qui d è dipendenti e r è reparti. Due
attenzioni:
- l'alias si usa con il punto:
d.nome, nondipendenti.nomenédNome; - l'
ASnelSELECTè un'altra cosa: rinomina la colonna del risultato (dipendente,reparto), non la tabella. Serve perché in uscita ci sarebbero due colonne chiamatenome.
5.2 Lo stesso nome due volte: il self-join
Le due tabelle non devono per forza essere diverse. Per confrontare ogni dipendente con gli
altri dello stesso reparto usiamo dipendenti due volte: la chiamiamo a
per il primo e b per il secondo.
SELECT CONCAT(a.cognome, ' ', a.nome) AS dipendente_1, CONCAT(b.cognome, ' ', b.nome) AS dipendente_2
FROM dipendenti a
JOIN dipendenti b ON b.reparto_id = a.reparto_id AND b.id > a.id
| dipendente_1 | dipendente_2 |
|---|---|
| Ferrari Anna | Greco Marco |
| Ferrari Anna | Rizzo Andrea |
| Greco Marco | Rizzo Andrea |
| Bianchi Sara | Conti Luca |
| Ricci Elena | Marino Paolo |
La condizione ha due parti: b.reparto_id = a.reparto_id dice che devono stare nello
stesso reparto, e b.id > a.id fa sì che ogni coppia compaia una volta
sola. Senza quel b.id > a.id, Ferrari Anna e Greco Marco comparirebbero
due volte: una come "Ferrari con Greco" e una come "Greco con Ferrari". Ricerca non produce
coppie, perché ha un solo dipendente.
5.3 Un esempio in più: tre tabelle, tre alias
Gli alias diventano davvero utili da tre tabelle in su, perché i nomi di colonna in comune aumentano. Qui vogliamo sapere cosa hanno comprato i clienti di Roma: servono tre tabelle e tre alias distinti.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, p.nome AS prodotto, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti p ON p.id = o.prodotto_id
WHERE c.citta = 'Roma'
| cliente | prodotto | importo |
|---|---|---|
| Bianchi Giulia | Cuffie senza fili | 89 |
| Bruno Elena | Mouse ergonomico | 35 |
Senza alias avremmo dovuto scrivere clienti.nome, prodotti.nome e
ordini.importo a ogni riga; con c, p e o la
query si legge come una frase. Ricorda che i tre alias devono essere diversi fra
loro: due tabelle con lo stesso alias creano una query ambigua, non un errore di
sintassi.
🔗 6. JOIN su Tre Tabelle
Finora abbiamo unito due tabelle. Ma ordini si collega a due tabelle diverse: ha
sia cliente_id sia prodotto_id. Per ottenere la scheda completa di un
ordine — chi ha comprato, cosa ha comprato — servono tre tabelle.
6.1 Il passo intermedio: ordine e prodotto
SELECT o.id AS ordine, o.data_ordine, p.nome AS prodotto, p.categoria, o.importo
FROM ordini o
JOIN prodotti p ON p.id = o.prodotto_id
| ordine | data_ordine | prodotto | categoria | importo |
|---|---|---|---|---|
| 1 | 2026-01-12 | Monitor 27 pollici | Elettronica | 329 |
| 2 | 2026-01-28 | Tastiera meccanica | Accessori | 79 |
| 3 | 2026-02-03 | Cuffie senza fili | Elettronica | 89 |
| 4 | 2026-02-11 | Monitor 27 pollici | Elettronica | 329 |
| 5 | 2026-02-19 | Lampada da tavolo | Arredo | 45 |
| 6 | 2026-03-02 | Sedia da ufficio | Arredo | 149 |
| 7 | 2026-03-08 | Stampante laser | Elettronica | 199 |
| 8 | 2026-03-15 | Mouse ergonomico | Accessori | 35 |
| 9 | 2026-03-21 | Tastiera meccanica | Accessori | 79 |
| 10 | 2026-04-02 | Sedia da ufficio | Arredo | 149 |
Dieci righe come prima: nessun ordine punta a un prodotto inesistente, e il prodotto 8 (Webcam HD) non è mai stato ordinato — per questo non compare.
6.2 Tre tabelle: cliente, ordine e prodotto
Si aggiunge un secondo JOIN, con la sua condizione. Ogni riga si costruisce in
cascata: prima clienti ⋈ ordini, poi il risultato si unisce a prodotti.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, c.citta, p.nome AS prodotto, p.categoria, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti p ON p.id = o.prodotto_id
| cliente | citta | prodotto | categoria | importo |
|---|---|---|---|---|
| Rossi Mario | Milano | Monitor 27 pollici | Elettronica | 329 |
| Rossi Mario | Milano | Tastiera meccanica | Accessori | 79 |
| Rossi Mario | Milano | Sedia da ufficio | Arredo | 149 |
| Bianchi Giulia | Roma | Cuffie senza fili | Elettronica | 89 |
| Ferrari Luca | Milano | Monitor 27 pollici | Elettronica | 329 |
| Greco Sara | Napoli | Lampada da tavolo | Arredo | 45 |
| Greco Sara | Napoli | Sedia da ufficio | Arredo | 149 |
| Conti Marco | Torino | Stampante laser | Elettronica | 199 |
| Bruno Elena | Roma | Mouse ergonomico | Accessori | 35 |
| De Luca Chiara | Bologna | Tastiera meccanica | Accessori | 79 |
📌 L'ordine conta
Le tabelle da usare sono le tre che contengono i dati richiesti, ma non si possono
combinare tutte insieme: il secondo JOIN non può fare riferimento a
clienti e prodotti finché non li abbiamo entrambi nella query.
Perciò ogni tabella viene agganciata a una tabella già presente (sezione 2.3:
la combinazione si costruisce un pezzo alla volta). Qui il passaggio obbligato è
ordini: è l'unica tabella che conosce sia il cliente sia il prodotto.
6.3 LEFT JOIN anche su tre tabelle
Il LEFT JOIN si può ripetere: ogni LEFT conserva tutte le righe della
tabella che lo precede, anche quando nella successiva non c'è corrispondenza.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, p.nome AS prodotto, o.importo
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
LEFT JOIN prodotti p ON p.id = o.prodotto_id
| cliente | prodotto | importo |
|---|---|---|
| Rossi Mario | Monitor 27 pollici | 329 |
| Rossi Mario | Tastiera meccanica | 79 |
| Rossi Mario | Sedia da ufficio | 149 |
| Bianchi Giulia | Cuffie senza fili | 89 |
| Ferrari Luca | Monitor 27 pollici | 329 |
| Greco Sara | Lampada da tavolo | 45 |
| Greco Sara | Sedia da ufficio | 149 |
| Conti Marco | Stampante laser | 199 |
| Bruno Elena | Mouse ergonomico | 35 |
| De Luca Chiara | Tastiera meccanica | 79 |
| Gallo Pietro | NULL | NULL |
Gallo Pietro c'è, e sia il prodotto sia l'importo sono NULL: non ha ordinato
niente, quindi non c'è nemmeno un prodotto da mostrare. Undici righe, come nel
LEFT JOIN a due tabelle: il secondo LEFT non aggiunge righe, perché
tutti gli ordini hanno il loro prodotto.
6.4 Errore: l'alias sbagliato
❌ SBAGLIATO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, p.nome
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti pr ON pr.id = o.prodotto_id;
La tabella è stata chiamata pr, non p: MariaDB risponde
Unknown column 'p.nome' in 'field list'.
✅ CORRETTO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, pr.nome
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti pr ON pr.id = o.prodotto_id;
L'alias usato nel SELECT deve essere identico a quello scritto dopo
JOIN. L'alias o è lo stesso in tutte le righe, e funziona
perché o è stato definito nella riga del FROM.
6.5 Un esempio in più: tre tabelle con ORDER BY e LIMIT
Il caso più frequente in assoluto: una classifica. Chi ha fatto gli acquisti più costosi?
Servono le tre tabelle per risalire dal cliente al prodotto, e poi ORDER BY con
LIMIT per prendere solo i primi.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, p.nome AS prodotto, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti p ON p.id = o.prodotto_id
ORDER BY o.importo DESC
LIMIT 4
| cliente | prodotto | importo |
|---|---|---|
| Rossi Mario | Monitor 27 pollici | 329 |
| Ferrari Luca | Monitor 27 pollici | 329 |
| Conti Marco | Stampante laser | 199 |
| Rossi Mario | Sedia da ufficio | 149 |
Due ordini hanno lo stesso importo (329): quale dei due venga prima non è garantito, e con
LIMIT il risultato può cambiare da un'esecuzione all'altra se il taglio cade su un
pareggio. Se serve un ordine stabile si aggiunge un secondo criterio, per esempio
ORDER BY o.importo DESC, o.id.
📊 7. JOIN con GROUP BY e le Funzioni Aggregate
Il GROUP BY funziona esattamente come su una tabella sola. Cambia solo che qui si
raggruppa su colonne che vengono da tabelle diverse. Se hai letto la
guida su GROUP BY hai già la teoria: qui vediamo la combinazione.
7.1 Raggruppare su una colonna dell'altra tabella
La domanda: quanto si è incassato per ogni categoria di prodotto? La categoria sta in
prodotti, gli importi in ordini: per metterli insieme serve il JOIN.
SELECT p.categoria, SUM(o.importo) AS totale
FROM ordini o
JOIN prodotti p ON p.id = o.prodotto_id
GROUP BY p.categoria
| categoria | totale |
|---|---|
| Elettronica | 946 |
| Accessori | 193 |
| Arredo | 343 |
Le tre categorie sono quelle dei prodotti ordinati: la Webcam HD è Elettronica, ma siccome nessuno l'ha ordinata non porta incassi e non crea nessun gruppo. Il totale complessivo 1482 è la somma di tutti e dieci gli ordini.
7.2 Filtrare i gruppi con HAVING
WHERE filtra le righe, HAVING filtra i gruppi già formati. Qui
vogliamo solo i clienti che hanno speso più di 300: è una condizione su un totale, quindi va in
HAVING.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, COUNT(o.id) AS numero_ordini, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome
HAVING SUM(o.importo) > 300
| cliente | numero_ordini | totale |
|---|---|---|
| Rossi Mario | 3 | 557 |
| Ferrari Luca | 1 | 329 |
7.3 Contare anche chi non ha ordinato
Qui c'è un trucco che vale la pena imparare. Se contiamo con COUNT(*), la riga in
cui la seconda tabella manca viene contata come 1, perché la riga del cliente
esiste lo stesso. Se invece contiamo una colonna della tabella agganciata, il NULL
non viene contato:
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, COUNT(o.id) AS numero_ordini
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome
| cliente | numero_ordini |
|---|---|
| Rossi Mario | 3 |
| Bianchi Giulia | 1 |
| Ferrari Luca | 1 |
| Greco Sara | 2 |
| Conti Marco | 1 |
| Bruno Elena | 1 |
| De Luca Chiara | 1 |
| Gallo Pietro | 0 |
Gallo Pietro ha numero_ordini = 0. Se avessimo scritto COUNT(*), la
sua riga sarebbe questa:
| cliente | COUNT(*) |
|---|---|
| Gallo Pietro | 1 |
Un ordine che non esiste contato come uno: esattamente l'errore da evitare.
🎯 LA REGOLA
COUNT(*) conta le righe. COUNT(colonna) conta i
valori non nulli di quella colonna. Con un LEFT JOIN la
differenza è tutta qui.
7.4 Un esempio in più: MIN e MAX per reparto
Non ci sono solo SUM e COUNT. Lo stipendio più basso e quello più
alto di ogni reparto si ottengono con MIN e MAX, e le colonne da
raggruppare e da aggregare stanno in due tabelle diverse:
SELECT r.nome AS reparto, MIN(d.stipendio) AS minimo, MAX(d.stipendio) AS massimo
FROM reparti r
JOIN dipendenti d ON d.reparto_id = r.id
GROUP BY r.nome
| reparto | minimo | massimo |
|---|---|---|
| Amministrazione | 36000 | 41000 |
| Produzione | 33000 | 42000 |
| Ricerca | 64000 | 64000 |
| Vendite | 47000 | 55000 |
Ricerca ha minimo e massimo uguali: ha un solo dipendente, quindi i due valori coincidono. È un caso che vale la pena riconoscere, perché non è un errore della query.
7.5 Un esempio in più: raggruppare su due colonne di due tabelle
Il GROUP BY accetta più colonne, e non devono venire dalla stessa tabella. Qui
raggruppiamo per città del cliente e categoria del prodotto insieme: otto gruppi,
perché le combinazioni possibili sono molte di più di tre.
SELECT c.citta, p.categoria, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti p ON p.id = o.prodotto_id
GROUP BY c.citta, p.categoria
| citta | categoria | totale |
|---|---|---|
| Bologna | Accessori | 79 |
| Milano | Accessori | 79 |
| Milano | Arredo | 149 |
| Milano | Elettronica | 658 |
| Napoli | Arredo | 194 |
| Roma | Accessori | 35 |
| Roma | Elettronica | 89 |
| Torino | Elettronica | 199 |
Milano compare tre volte, una per categoria: sono tre gruppi diversi, non un cliente ripetuto. La somma dei totali è sempre 1482, come nella sezione 7.1 — cambia solo come sono distribuiti i gruppi.
📈 8. JOIN con ORDER BY e LIMIT
Quasi tutte le query che si fanno davvero su più tabelle sono report: un totale per cliente, ordinato dal migliore al peggiore, limitato alle prime righe. Si scrive come nella guida di base, ma il totale viene calcolato su dati che arrivano da due tabelle.
8.1 Il report completo
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome
ORDER BY totale DESC
| cliente | totale |
|---|---|
| Rossi Mario | 557 |
| Ferrari Luca | 329 |
| Conti Marco | 199 |
| Greco Sara | 194 |
| Bianchi Giulia | 89 |
| De Luca Chiara | 79 |
| Bruno Elena | 35 |
I clienti senza ordini sono spariti: il JOIN li ha scartati. Per includerli serve
il LEFT JOIN — e allora Gallo Pietro comparirebbe con totale NULL, che
in un report di soldi è meno utile di uno 0.
8.2 Il LIMIT: solo i primi tre
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome
ORDER BY totale DESC
LIMIT 3
| cliente | totale |
|---|---|
| Rossi Mario | 557 |
| Ferrari Luca | 329 |
| Conti Marco | 199 |
📌 L'ordine delle clausole non è libero
Anche con più tabelle l'ordine è sempre quello della guida di base:
SELECT → FROM con i JOIN → WHERE →
GROUP BY → HAVING → ORDER BY → LIMIT.
Scrivere il WHERE prima del FROM non funziona: l'ordine delle
clausole non è libero, ed è uno degli errori più comuni.
8.3 Un esempio in più: ordinare per una colonna dell'altra tabella
Nell'ORDER BY si può usare qualsiasi colonna comparsa nel
SELECT, anche una che viene dalla tabella agganciata e anche il nome di un alias.
Qui ordiniamo prima per città e poi, dentro ogni città, dal cliente che ha speso di più:
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, c.citta, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome, c.citta
ORDER BY c.citta, totale DESC
| cliente | citta | totale |
|---|---|---|
| De Luca Chiara | Bologna | 79 |
| Rossi Mario | Milano | 557 |
| Ferrari Luca | Milano | 329 |
| Greco Sara | Napoli | 194 |
| Bianchi Giulia | Roma | 89 |
| Bruno Elena | Roma | 35 |
| Conti Marco | Torino | 199 |
Due criteri in sequenza: il primo divide in blocchi (le città, in ordine alfabetico crescente,
perché non ho scritto DESC), il secondo ordina dentro ogni blocco. Nota che
nel GROUP BY ho dovuto elencare sia c.nome sia c.citta:
raggruppando solo per nome, il valore di città mostrato per ogni gruppo non sarebbe
garantito.
🧩 9. JOIN e Subquery: quando usare l'uno e quando l'altro
La guida sulle subquery ha spiegato che a una domanda si può spesso rispondere in due modi diversi. Vale anche per il JOIN, e saper scegliere fra i due è quello che distingue chi scrive SQL da chi lo imita.
Usare il JOIN quando…
- ti servono i dati di entrambe le tabelle nelle colonne;
- vuoi aggregare insieme le informazioni delle due tabelle.
Usare la subquery quando…
- ti serve solo un confronto (magari superiore alla media);
- la domanda è "chi ha / chi non ha", senza volere i dati dell'altra tabella;
- la subquery è un valore di calcolo, non un'altra fonte di colonne.
E spesso la risposta è: entrambi nella stessa query. È quello che succede nelle prossime righe.
9.1 JOIN + subquery come soglia
Il caso più semplice: prendi le righe del JOIN, ma tieni solo quelle che superano la media calcolata su tutta la tabella.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
WHERE o.importo > (SELECT AVG(importo) FROM ordini)
| cliente | importo |
|---|---|
| Rossi Mario | 329 |
| Rossi Mario | 149 |
| Ferrari Luca | 329 |
| Greco Sara | 149 |
| Conti Marco | 199 |
La media degli importi è 148.2: le cinque righe qui sopra sono gli ordini che la superano. Il JOIN serve a ottenere il nome del cliente, la subquery a calcolare la media. Nessuno dei due fa il lavoro dell'altro.
9.2 Due modi per elencare i clienti che hanno ordinato
Prima con DISTINCT: il JOIN produce dieci righe, DISTINCT le riduce ai
nomi diversi.
SELECT DISTINCT CONCAT(c.cognome, ' ', c.nome) AS cliente
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
Poi con EXISTS, che chiede solo se esiste almeno una riga corrispondente:
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente
FROM clienti c
WHERE EXISTS (SELECT 1 FROM ordini o WHERE o.cliente_id = c.id)
| cliente |
|---|
| Rossi Mario |
| Bianchi Giulia |
| Ferrari Luca |
| Greco Sara |
| Conti Marco |
| Bruno Elena |
| De Luca Chiara |
Stesse 7 righe, due scritture diverse. Con molti ordini per cliente
DISTINCT fa fatica, perché prima costruisce tutte le righe e poi scarta i duplicati;
EXISTS si ferma al primo ordine trovato. Quando servono solo i nomi, e non i
dati degli ordini, EXISTS è la forma più pulita.
9.3 Due modi per dire "mai ordinato"
Con il LEFT JOIN e un IS NULL troviamo i prodotti mai ordinati:
SELECT p.nome AS prodotto, p.categoria
FROM prodotti p
LEFT JOIN ordini o ON o.prodotto_id = p.id
WHERE o.id IS NULL
| prodotto | categoria |
|---|---|
| Webcam HD | Elettronica |
Con NOT EXISTS, invece, la domanda è diversa: quali prodotti non sono mai stati
comprati da un cliente di Milano? Qui la subquery unisce ordini e
clienti al suo interno:
SELECT p.nome AS prodotto
FROM prodotti p
WHERE NOT EXISTS (
SELECT 1
FROM ordini o
JOIN clienti c ON c.id = o.cliente_id
WHERE o.prodotto_id = p.id AND c.citta = 'Milano'
)
| prodotto |
|---|
| Cuffie senza fili |
| Lampada da tavolo |
| Stampante laser |
| Mouse ergonomico |
| Webcam HD |
Sono due domande diverse, non due scritture della stessa: la prima dà un prodotto, la seconda cinque. A Milano abitano Rossi Mario (che ha comprato Monitor, Tastiera e Sedia), Ferrari Luca (Monitor) e Gallo Pietro (che non ha mai ordinato): tutti gli altri prodotti non sono mai stati comprati da un milanese.
9.4 JOIN + GROUP BY + subquery in HAVING
La media degli stipendi serve a filtrare i gruppi, quindi va nel
HAVING. La media la calcola la subquery.
SELECT r.nome AS reparto, AVG(d.stipendio) AS stipendio_medio
FROM reparti r
JOIN dipendenti d ON d.reparto_id = r.id
GROUP BY r.nome
HAVING AVG(d.stipendio) > (SELECT AVG(stipendio) FROM dipendenti)
| reparto | stipendio_medio |
|---|---|
| Vendite | 51000 |
| Ricerca | 64000 |
La media aziendale è 44500; i quattro reparti hanno medie 37666.67, 51000, 38500 e 64000. Solo due la superano.
9.5 La subquery correlata: il JOIN che guarda se stesso
Questa è la forma più potente e quella che spaventa di più all'inizio. La subquery usa la
colonna della tabella esterna (c.id), quindi va ricalcolata per ogni riga
esterna: si chiama correlata.
SELECT CONCAT(c.cognome, ' ', c.nome) AS cliente, o.id AS ordine, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
WHERE o.importo = (
SELECT MAX(o2.importo) FROM ordini o2 WHERE o2.cliente_id = c.id
)
| cliente | ordine | importo |
|---|---|---|
| Rossi Mario | 1 | 329 |
| Bianchi Giulia | 3 | 89 |
| Ferrari Luca | 4 | 329 |
| Greco Sara | 6 | 149 |
| Conti Marco | 7 | 199 |
| Bruno Elena | 8 | 35 |
| De Luca Chiara | 9 | 79 |
Per ogni ordine la subquery chiede: è questo l'importo più alto fra gli ordini di questo
cliente? La subquery guarda il cliente corrente — c.id: è questo che la rende
correlata. L'alias o2 serve a distinguere la seconda copia di ordini
dalla prima, esattamente come nel self-join della sezione 5.
9.6 Contare i clienti distinti
Un COUNT normale conta le righe, e un prodotto ordinato due volte dà
2. Se invece ci interessa quanti clienti diversi lo hanno ordinato, serve
COUNT(DISTINCT ...).
SELECT p.nome AS prodotto, COUNT(DISTINCT o.cliente_id) AS clienti
FROM prodotti p
JOIN ordini o ON o.prodotto_id = p.id
GROUP BY p.nome
HAVING COUNT(DISTINCT o.cliente_id) >= 2
| prodotto | clienti |
|---|---|
| Tastiera meccanica | 2 |
| Monitor 27 pollici | 2 |
| Sedia da ufficio | 2 |
Sono esattamente i tre prodotti ordinati due volte, da due clienti diversi. Se avessimo contato
COUNT(o.cliente_id), il risultato sarebbe lo stesso qui — ma solo perché ogni
prodotto è stato ordinato al massimo una volta per cliente.
📌 Non confondere le due COUNT
COUNT(o.cliente_id) conta gli ordini.
COUNT(DISTINCT o.cliente_id) conta i clienti. Se Rossi Mario
ordinasse la stessa cosa tre volte, il primo direbbe 3 e il secondo 1.
⚠️ 10. USING e NATURAL JOIN: perché si evitano
MariaDB accetta due modi più brevi per scrivere un JOIN. Per completezza conviene conoscerli, ma in questa guida non li usiamo, e le righe seguenti spiegano perché.
JOIN dipendenti d USING (id)
id viene cercata da sola in entrambe le tabelle. Ma id non è la chiave esterna giusta: quella è reparto_id, quindi questa query unisce righe sbagliate.NATURAL JOIN dipendenti d
id e nome insieme: nessuna riga corrisponde, perché un dipendente ha nome diverso dal reparto.Perché sono una scelta sconsigliata
Problemi concreti
- Non si vede cosa viene unito. In
ONleggi la condizione; conUSINGoNATURALdevi andare a controllare il modello per sapere quali colonne sono state usate. - Un
NATURAL JOINpuò cambiare significato da solo. Se domani aggiungi una colonnasedesia arepartisia adipendenti, la query comincia a unire anche su quella, senza che tu abbia scritto niente. - Non puoi scegliere la direzione. Con
NATURALnon esiste la varianteLEFT: si può solo sperare che le colonne omonime siano quelle giuste. - Non tutti i motori li supportano. Il motore di riserva della
finestra esercizi (AlaSQL), per esempio, non implementa
USING.
Cosa fare invece
Usare sempre JOIN con ON, che è la forma che le altre pagine
di questa guida usano ovunque e che tutti i motori capiscono:
JOIN dipendenti d ON d.reparto_id = r.id
Se in un esercizio o in un progetto incontri un NATURAL JOIN, riscrivilo
con la condizione esplicita: si capisce subito su quali colonne poggia.
La finestra esercizi di questa pagina rifiuta USING e NATURAL prima
ancora di eseguirli, con un messaggio che rimanda a questa sezione: servono a farti vedere
l'errore, non a fartelo scrivere.
⚠️ 11. Errori Comuni
11.1 Il filtro che cancella il LEFT JOIN
Questo è l'errore più insidioso di tutti, perché la query funziona: restituisce un risultato, solo che non è più quello che credevamo.
Vogliamo gli ordini da marzo in poi, mostrando anche i clienti che non ne hanno. Scriviamo:
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo, o.data_ordine
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
WHERE o.data_ordine >= '2026-03-01'
| nome | importo | data_ordine |
|---|---|---|
| Greco Sara | 149 | 2026-03-02 |
| Conti Marco | 199 | 2026-03-08 |
| Bruno Elena | 35 | 2026-03-15 |
| De Luca Chiara | 79 | 2026-03-21 |
| Rossi Mario | 149 | 2026-04-02 |
Sono 5 righe: il WHERE ha eliminato tutti i clienti senza ordini,
proprio come farebbe un JOIN. Il motivo è che la condizione su
o.data_ordine viene valutata dopo il join, e per le righe senza
corrispondenza vale NULL >= '2026-03-01', che non è vero: NULL
confrontato con qualsiasi cosa dà un risultato sconosciuto, e lo sconosciuto in un
WHERE vale come "falso".
❌ SBAGLIATO
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
WHERE o.data_ordine >= '2026-03-01'
Il filtro sulle colonne della tabella agganciata annulla il LEFT JOIN.
✅ CORRETTO
FROM clienti c
LEFT JOIN ordini o
ON o.cliente_id = c.id
AND o.data_ordine >= '2026-03-01'
Il filtro va dentro l'ON: così scegli quali righe abbinare, e le righe
senza abbinamento restano.
Con questa correzione le righe diventano 8: i 5 ordini da marzo, più i 3
clienti che da marzo non hanno ordinato niente (Bianchi Giulia, Ferrari Luca e Gallo Pietro), che
restano con NULL.
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo, o.data_ordine
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
AND o.data_ordine >= '2026-03-01'
| nome | importo | data_ordine |
|---|---|---|
| Rossi Mario | 149 | 2026-04-02 |
| Bianchi Giulia | NULL | NULL |
| Ferrari Luca | NULL | NULL |
| Greco Sara | 149 | 2026-03-02 |
| Conti Marco | 199 | 2026-03-08 |
| Bruno Elena | 35 | 2026-03-15 |
| De Luca Chiara | 79 | 2026-03-21 |
| Gallo Pietro | NULL | NULL |
🎯 LA REGOLA
Nel WHERE metti solo filtri sulla prima tabella; i filtri sulla seconda
vanno dentro l'ON del LEFT JOIN. Con un normale
JOIN la distinzione non conta, perché la riga senza corrispondenza viene comunque
scartata.
11.2 Il JOIN senza la condizione di ON
❌ SBAGLIATO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo
FROM clienti c JOIN ordini o;
MariaDB accetta la sintassi — JOIN senza ON è un prodotto
cartesiano — e abbina ogni cliente con ogni ordine: 8 × 10 =
80 righe. Nessun errore, solo un risultato inutile.
✅ CORRETTO
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id;
La condizione di ON non è mai facoltativa: è il cuore del
JOIN.
11.3 Il JOIN scritto dentro il WHERE
Molti libri di testo vecchi, e qualche esame, usano la forma senza la parola JOIN:
una virgola nel FROM e la condizione nel WHERE. Funziona, ma
non è un JOIN e con il LEFT JOIN non è nemmeno possibile scriverla
così.
❌ FORMA VECCHIA
FROM clienti c, ordini o
WHERE c.id = o.cliente_id
Funziona, ma la condizione che lega le tabelle è nascosta fra i filtri, e non
esiste modo di renderla un LEFT JOIN.
✅ FORMA MODERNA
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
La condizione di legame sta in ON, dove si vede subito, e il
WHERE resta per i filtri.
11.4 Contare le righe sbagliate
❌ SBAGLIATO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, COUNT(*) AS ordini
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome;
Gallo Pietro, che non ha ordini, risulta averne 1.
✅ CORRETTO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, COUNT(o.id) AS ordini
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome;
Contando o.id, che è NULL quando non c'è corrispondenza, il
risultato è 0.
11.5 Il filtro sui gruppi messo in WHERE
Il gemello dell'errore 11.1, ma sul GROUP BY: una condizione che parla di un
totale non può stare nel WHERE, perché il WHERE lavora prima
che i gruppi esistano.
❌ SBAGLIATO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
WHERE SUM(o.importo) > 300;
MariaDB rifiuta la query: Invalid use of group function. Nel
WHERE non esiste ancora nessun totale da confrontare, perché i gruppi non
sono stati formati.
✅ CORRETTO
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome
HAVING SUM(o.importo) > 300;
Con GROUP BY e HAVING la query risponde: Rossi Mario
(557) e Ferrari Luca (329).
La regola da tenere a mente è l'ordine in cui il database lavora: prima
FROM e JOIN costruiscono le righe, poi WHERE le filtra,
poi GROUP BY le raggruppa, e solo allora HAVING può parlare dei
gruppi. Ogni clausola può usare solo ciò che esiste in quel momento.
📝 12. Esercizi
Tredici esercizi sulle tabelle di questa guida, dal più semplice al più completo, tutti con la soluzione da aprire solo dopo aver provato. Per esercitarti su dataset sempre diversi usa la finestra esercizi: genera query su due o tre tabelle e ti dice se il risultato è quello giusto. Il pulsante è anche in fondo alla pagina, come nelle altre guide.
Scrivi la query e il trainer la esegue davvero, anche con tre tabelle in gioco, e ti dice se il risultato è corretto. Gli esercizi usano solo il JOIN e le operazioni delle sezioni 3-11: nessuna clausola fuori programma viene proposta, nemmeno per errore.
Esercizio 1 - Base Base
Mostra il nome di ogni cliente e la città in cui abita, con il numero di ordini che ha fatto. Devono comparire anche i clienti che non hanno mai ordinato.
Mostra Soluzione
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, c.citta, COUNT(o.id) AS numero_ordini
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome, c.citta;
Otto righe: i sette clienti con ordini e Gallo Pietro con 0. Il
COUNT(o.id) è quello che rende possibile lo zero.
Esercizio 2 - JOIN Base
Per ogni ordine mostra la data e il nome del prodotto ordinato.
Mostra Soluzione
SELECT o.data_ordine, p.nome AS prodotto
FROM ordini o
JOIN prodotti p ON p.id = o.prodotto_id;
Dieci righe, una per ordine. La Webcam HD non compare: nessun ordine la cita.
Esercizio 3 - LEFT JOIN Base
Elenca i prodotti che non sono mai stati ordinati, con la loro categoria.
Mostra Soluzione
SELECT p.nome, p.categoria
FROM prodotti p
LEFT JOIN ordini o ON o.prodotto_id = p.id
WHERE o.id IS NULL;
Una riga: Webcam HD, Elettronica. È il caso speculare a quello della sezione 3: la riga che il JOIN scarta, qui la recuperiamo.
Esercizio 4 - Tre tabelle Intermedio
Per ogni cliente di Milano mostra il nome del prodotto che ha ordinato e l'importo.
Mostra Soluzione
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, p.nome AS prodotto, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
JOIN prodotti p ON p.id = o.prodotto_id
WHERE c.citta = 'Milano';
Quattro righe: tre di Rossi Mario e una di Ferrari Luca. Gallo Pietro è di Milano, ma non ha ordini, quindi non entra.
Esercizio 5 - Aggregazione Intermedio
Calcola quanto ha speso ogni cliente, mostrandone la città, e tieni solo chi ha speso più di 200 euro.
Mostra Soluzione
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, c.citta, SUM(o.importo) AS totale
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
GROUP BY c.cognome, c.nome, c.citta
HAVING SUM(o.importo) > 200;
Due righe: Rossi Mario (557) e Ferrari Luca (329). Il filtro è su un totale, quindi
va in HAVING e non in WHERE.
Esercizio 6 - JOIN e subquery Intermedio
Mostra i clienti che hanno almeno un ordine con importo superiore alla media di tutti gli ordini.
Mostra Soluzione
SELECT DISTINCT CONCAT(c.cognome, ' ', c.nome) AS nome, c.citta
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
WHERE o.importo > (SELECT AVG(importo) FROM ordini);
Quattro righe: Rossi Mario, Ferrari Luca, Greco Sara e Conti Marco. Serve
DISTINCT perché Rossi Mario ha due ordini sopra la media.
Esercizio 7 - JOIN e subquery correlata Avanzato
Per ogni cliente, mostra il suo ordine più costoso.
Mostra Soluzione
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, o.importo
FROM clienti c
JOIN ordini o ON o.cliente_id = c.id
WHERE o.importo = (
SELECT MAX(o2.importo) FROM ordini o2
WHERE o2.cliente_id = c.id
);
Sette righe, una per cliente con ordini. La subquery è correlata: usa
c.id, quindi viene ricalcolata per ogni cliente.
Esercizio 8 - Report Avanzato
Mostra le tre categorie di prodotto che hanno generato più incassi, dalla maggiore alla minore.
Mostra Soluzione
SELECT p.categoria, SUM(o.importo) AS totale
FROM ordini o
JOIN prodotti p ON p.id = o.prodotto_id
GROUP BY p.categoria
ORDER BY totale DESC
LIMIT 3;
Elettronica 946, Arredo 343, Accessori 193. Le categorie sono tre, quindi il
LIMIT 3 non taglia niente: serve a mostrare la clausola.
Esercizio 9 - Ordinamento Intermedio
Elenca ogni dipendente con il nome del suo reparto e lo stipendio, dal più alto al più basso.
Mostra Soluzione
SELECT CONCAT(d.cognome, ' ', d.nome) AS nome, r.nome AS reparto, d.stipendio
FROM dipendenti d
JOIN reparti r ON r.id = d.reparto_id
ORDER BY d.stipendio DESC;
Otto righe, da Gallo Giulia (64000) a Rizzo Andrea (33000). Senza ORDER BY l'ordine non sarebbe garantito.
Esercizio 10 - Conteggio per gruppo Intermedio
Per ogni categoria di prodotto, conta quanti ordini sono stati fatti. Mostra le categorie dalla più ordinata alla meno ordinata.
Mostra Soluzione
SELECT p.categoria, COUNT(o.id) AS numero_ordini
FROM ordini o
JOIN prodotti p ON p.id = o.prodotto_id
GROUP BY p.categoria
ORDER BY numero_ordini DESC;
Elettronica 4, Arredo 3, Accessori 3. Le categorie sono quelle dei prodotti ordinati: la Webcam HD è Elettronica ma non crea ordini.
Esercizio 11 - Chi non ha Intermedio
Elenca nome e città dei clienti che non hanno mai ordinato.
Mostra Soluzione
SELECT CONCAT(c.cognome, ' ', c.nome) AS nome, c.citta
FROM clienti c
LEFT JOIN ordini o ON o.cliente_id = c.id
WHERE o.id IS NULL;
Una riga: Gallo Pietro, Milano. Lo stesso schema dell'esercizio 3, applicato all'altra coppia di tabelle.
Esercizio 12 - Prodotto mai venduto Avanzato
Elenca nome e prezzo dei prodotti che non sono mai stati ordinati.
Mostra Soluzione
SELECT p.nome, p.prezzo
FROM prodotti p
LEFT JOIN ordini o ON o.prodotto_id = p.id
WHERE o.id IS NULL;
Una riga: Webcam HD, 129. È l'esercizio 3 con una colonna in più, e mostra che il trucco funziona su qualunque coppia di tabelle.
Esercizio 13 - Due tipi di JOIN Avanzato
Elenca tutti gli otto dipendenti con il nome del loro reparto, e per ogni riga indica se il reparto esiste oppure no.
Mostra Soluzione
SELECT CONCAT(d.cognome, ' ', d.nome) AS dipendente, r.nome AS reparto
FROM dipendenti d
LEFT JOIN reparti r ON r.id = d.reparto_id;
Otto righe e nessun NULL: nel nostro dataset ogni dipendente ha il suo reparto, quindi qui il LEFT JOIN e il JOIN danno lo stesso risultato. Serve a capire che il LEFT JOIN non cambia niente quando le corrispondenze ci sono tutte: protegge solo il caso in cui manchino.
📌 13. Riepilogo Finale
Le formule da ricordare
-- unire due tabelle, tenendo solo chi ha corrispondenza
FROM a JOIN b ON b.id_a = a.id
-- unire due tabelle, tenendo TUTTE le righe della prima
FROM a LEFT JOIN b ON b.id_a = a.id
-- chi non ha corrispondenza: LEFT JOIN e poi il NULL
FROM a LEFT JOIN b ON b.id_a = a.id
WHERE b.id IS NULL
-- tre tabelle: si aggancia sempre a una tabella già presente
FROM a JOIN b ON … JOIN c ON …
Le cose da non dimenticare
ONè obbligatorio e dice quali righe si abbinano: senza, si ottiene il prodotto cartesiano.JOIN=INNER JOIN: scarta chi non ha corrispondenza.LEFT JOINtiene tutte le righe della prima tabella e riempie le altre conNULL.- Colonne ambigue: si risolvono con il nome della tabella, o meglio con un alias.
NULLnon si confronta con=: si testa conIS NULL/IS NOT NULL.- Col
LEFT JOIN, i filtri sulla seconda tabella vanno inON, non inWHERE. COUNT(*)conta le righe,COUNT(colonna)i valori non nulli.USINGeNATURAL JOINsi evitano: rendono la query illeggibile e cambiano significato da soli.
Le pagine che ti aiutano dopo
- Query fondamentali —
SELECT,WHERE,ORDER BY,LIMIT. - GROUP BY e HAVING — i raggruppamenti e i filtri sui gruppi.
- Le subquery —
IN,EXISTS, le subquery correlate. - Le forme normali — perché i dati sono distribuiti su più tabelle.
Un esercizio nuovo ogni volta, su due o tre tabelle collegate, con tre livelli di difficoltà e tre suggerimenti progressivi. Il trainer esegue davvero la tua query e ti dice se il risultato è quello giusto.
ONva l'uguaglianza fra la chiave della prima tabella e la chiave esterna della seconda.