🔗 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.

clienti(id(PK), cognome, nome, citta)
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

🗄️ Tabella: clienti
Tabella clienti: dati di esempio usati in tutti gli esempi
idcognomenomecitta
1RossiMarioMilano
2BianchiGiuliaRoma
3FerrariLucaMilano
4GrecoSaraNapoli
5ContiMarcoTorino
6BrunoElenaRoma
7De LucaChiaraBologna
8GalloPietroMilano

L'id 8, Gallo Pietro, non compare in nessun ordine: è il cliente che useremo per spiegare il LEFT JOIN.

Gli ordini

🗄️ Tabella: ordini
Tabella ordini: dati di esempio usati in tutti gli esempi
idcliente_idprodotto_idimportodata_ordine
1113292026-01-12
212792026-01-28
323892026-02-03
4313292026-02-11
544452026-02-19
6451492026-03-02
7561992026-03-08
867352026-03-15
972792026-03-21
10151492026-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

🗄️ Tabella: prodotti
Tabella prodotti: dati di esempio usati in tutti gli esempi
idnomecategoriaprezzo
1Monitor 27 polliciElettronica329
2Tastiera meccanicaAccessori79
3Cuffie senza filiElettronica89
4Lampada da tavoloArredo45
5Sedia da ufficioArredo149
6Stampante laserElettronica199
7Mouse ergonomicoAccessori35
8Webcam HDElettronica129

Il prodotto 8, la Webcam HD, non compare in nessun ordine: è il caso speculare a Gallo Pietro.

Chi lavora dove

🗄️ Tabella: reparti
Tabella reparti: dati di esempio usati in tutti gli esempi
idnomesedebudget
1ProduzioneMilano1200000
2VenditeMilano900000
3AmministrazioneTorino600000
4RicercaBologna750000
🗄️ Tabella: dipendenti
Tabella dipendenti: dati di esempio usati in tutti gli esempi
idcognomenomereparto_idstipendiodata_assunzione
1FerrariAnna1420002019-09-02
2GrecoMarco1380002021-03-15
3RizzoAndrea1330002023-01-09
4BianchiSara2550002018-05-21
5ContiLuca2470002020-11-03
6RicciElena3360002022-07-18
7MarinoPaolo3410002019-02-11
8GalloGiulia4640002017-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:

Le informazioni sul cliente Rossi Mario
idnomecitta
1RossiMarioMilano

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:

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:

Le prime combinazioni fra clienti e ordini, senza alcuna condizione
clienteordinecliente_idgiusta?
Rossi Mario11✅ sì
Rossi Mario21✅ sì
Rossi Mario32❌ no: l'ordine 3 è di un altro cliente
Rossi Mario43❌ no: l'ordine 4 è di un altro cliente
Rossi Mario54❌ 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:

La tabella combinata: ogni riga è una combinazione di un cliente e di un suo ordine
clienteordinecliente_idimporto
Rossi Mario11329
Rossi Mario2179
Rossi Mario101149
Bianchi Giulia3289
Ferrari Luca43329
Greco Sara5445
Greco Sara64149
Conti Marco75199
Bruno Elena8635
De Luca Chiara9779

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
Risultato: quanto ha speso ogni cliente
clientetotale
Rossi Mario557
Ferrari Luca329
Conti Marco199
Greco Sara194
Bianchi Giulia89
De Luca Chiara79
Bruno Elena35

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

La forma generale
SELECT …
FROM tabella_1
JOIN tabella_2 ON tabella_1.chiave = tabella_2.chiave_esterna
Su ON va l'uguaglianza fra la chiave della prima tabella e la chiave esterna della seconda.

Prima di scrivere bisogna decidere quali tabelle servono. Non è una scelta libera: dipende da quali dati chiede la traccia.

  1. 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) e ordini (per l'importo).
  2. Il numero minimo di tabelle: si prendono solo quelle che contengono almeno un dato richiesto, o che servono a collegare due tabelle altrimenti scollegate.
  3. 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 ordini collega clienti e prodotti.

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ì.

Cosa si trova nella tabella ordini e cosa richiede il JOIN
dato richiestodove staserve il JOIN?
il numero del clienteordini.cliente_idno
l'importo, la dataordini.importo, ordini.data_ordineno
il nome del clienteclienti.nomesì
la città del clienteclienti.cittasì

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:

Una riga con chiave esterna senza corrispondenza: la si perde solo con il JOIN
idcliente_idcon FROM ordinicon il JOIN
11c'èc'è
…………
11NULLc'è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

La riga dell'ordine numero 4
idcliente_idprodotto_idimportodata_ordine
4313292026-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

La riga del cliente numero 3
idnomecitta
3Ferrari LucaMilano

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.

La riga combinazione dell'ordine 4: le quattro colonne di clienti e le cinque di ordini
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.

Il risultato: le colonne scelte dalla riga combinazione
nomeimportodata_ordine
Ferrari Luca3292026-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
Risultato: ogni ordine con il nome del cliente che lo ha fatto
nomeimportodata_ordine
Rossi Mario3292026-01-12
Rossi Mario792026-01-28
Rossi Mario1492026-04-02
Bianchi Giulia892026-02-03
Ferrari Luca3292026-02-11
Greco Sara452026-02-19
Greco Sara1492026-03-02
Conti Marco1992026-03-08
Bruno Elena352026-03-15
De Luca Chiara792026-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
Risultato: i nomi dei clienti che hanno almeno un ordine
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'
Risultato: solo gli ordini dei clienti di Milano
nomeimportodata_ordine
Rossi Mario792026-01-28
Rossi Mario1492026-04-02
Rossi Mario3292026-01-12
Ferrari Luca3292026-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
Risultato: tutti i clienti, compresi quelli senza ordini
nomeimportodata_ordine
Rossi Mario3292026-01-12
Rossi Mario792026-01-28
Rossi Mario1492026-04-02
Bianchi Giulia892026-02-03
Ferrari Luca3292026-02-11
Greco Sara452026-02-19
Greco Sara1492026-03-02
Conti Marco1992026-03-08
Bruno Elena352026-03-15
De Luca Chiara792026-03-21
Gallo PietroNULLNULL

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
Risultato: i clienti che non hanno mai ordinato
nomecitta
Gallo PietroMilano

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'
Risultato: i prodotti Accessori con i loro ordini, se ci sono
prodottoordine
Tastiera meccanica2
Tastiera meccanica9
Mouse ergonomico8

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
Risultato: ogni dipendente con il nome del suo reparto
dipendenterepartostipendio
Ferrari AnnaProduzione42000
Greco MarcoProduzione38000
Rizzo AndreaProduzione33000
Bianchi SaraVendite55000
Conti LucaVendite47000
Ricci ElenaAmministrazione36000
Marino PaoloAmministrazione41000
Gallo GiuliaRicerca64000

Qui d è dipendenti e r è reparti. Due attenzioni:

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
Risultato: le coppie di colleghi dello stesso reparto
dipendente_1dipendente_2
Ferrari AnnaGreco Marco
Ferrari AnnaRizzo Andrea
Greco MarcoRizzo Andrea
Bianchi SaraConti Luca
Ricci ElenaMarino 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'
Risultato: gli acquisti dei clienti di Roma
clienteprodottoimporto
Bianchi GiuliaCuffie senza fili89
Bruno ElenaMouse ergonomico35

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
Risultato: ogni ordine con il prodotto ordinato
ordinedata_ordineprodottocategoriaimporto
12026-01-12Monitor 27 polliciElettronica329
22026-01-28Tastiera meccanicaAccessori79
32026-02-03Cuffie senza filiElettronica89
42026-02-11Monitor 27 polliciElettronica329
52026-02-19Lampada da tavoloArredo45
62026-03-02Sedia da ufficioArredo149
72026-03-08Stampante laserElettronica199
82026-03-15Mouse ergonomicoAccessori35
92026-03-21Tastiera meccanicaAccessori79
102026-04-02Sedia da ufficioArredo149

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
Risultato: chi ha comprato cosa
clientecittaprodottocategoriaimporto
Rossi MarioMilanoMonitor 27 polliciElettronica329
Rossi MarioMilanoTastiera meccanicaAccessori79
Rossi MarioMilanoSedia da ufficioArredo149
Bianchi GiuliaRomaCuffie senza filiElettronica89
Ferrari LucaMilanoMonitor 27 polliciElettronica329
Greco SaraNapoliLampada da tavoloArredo45
Greco SaraNapoliSedia da ufficioArredo149
Conti MarcoTorinoStampante laserElettronica199
Bruno ElenaRomaMouse ergonomicoAccessori35
De Luca ChiaraBolognaTastiera meccanicaAccessori79

📌 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
Risultato: tutti i clienti con il prodotto ordinato, se c'è
clienteprodottoimporto
Rossi MarioMonitor 27 pollici329
Rossi MarioTastiera meccanica79
Rossi MarioSedia da ufficio149
Bianchi GiuliaCuffie senza fili89
Ferrari LucaMonitor 27 pollici329
Greco SaraLampada da tavolo45
Greco SaraSedia da ufficio149
Conti MarcoStampante laser199
Bruno ElenaMouse ergonomico35
De Luca ChiaraTastiera meccanica79
Gallo PietroNULLNULL

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
Risultato: i quattro ordini più costosi, dal maggiore
clienteprodottoimporto
Rossi MarioMonitor 27 pollici329
Ferrari LucaMonitor 27 pollici329
Conti MarcoStampante laser199
Rossi MarioSedia da ufficio149

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
Risultato: incasso totale per categoria di prodotto
categoriatotale
Elettronica946
Accessori193
Arredo343

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
Risultato: i clienti che hanno speso più di 300
clientenumero_ordinitotale
Rossi Mario3557
Ferrari Luca1329

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
Risultato: quanti ordini ha fatto ogni cliente, zero compreso
clientenumero_ordini
Rossi Mario3
Bianchi Giulia1
Ferrari Luca1
Greco Sara2
Conti Marco1
Bruno Elena1
De Luca Chiara1
Gallo Pietro0

Gallo Pietro ha numero_ordini = 0. Se avessimo scritto COUNT(*), la sua riga sarebbe questa:

Confronto: la riga di Gallo Pietro con COUNT(*)
clienteCOUNT(*)
Gallo Pietro1

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
Risultato: stipendio minimo e massimo di ogni reparto
repartominimomassimo
Amministrazione3600041000
Produzione3300042000
Ricerca6400064000
Vendite4700055000

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
Risultato: incasso per città e categoria
cittacategoriatotale
BolognaAccessori79
MilanoAccessori79
MilanoArredo149
MilanoElettronica658
NapoliArredo194
RomaAccessori35
RomaElettronica89
TorinoElettronica199

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
Risultato: i clienti ordinati per totale speso, dal maggiore
clientetotale
Rossi Mario557
Ferrari Luca329
Conti Marco199
Greco Sara194
Bianchi Giulia89
De Luca Chiara79
Bruno Elena35

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
Risultato: i tre clienti che hanno speso di più
clientetotale
Rossi Mario557
Ferrari Luca329
Conti Marco199

📌 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
Risultato: i clienti per città, e dentro ogni città dal totale maggiore
clientecittatotale
De Luca ChiaraBologna79
Rossi MarioMilano557
Ferrari LucaMilano329
Greco SaraNapoli194
Bianchi GiuliaRoma89
Bruno ElenaRoma35
Conti MarcoTorino199

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)
Risultato: gli ordini con importo sopra la media
clienteimporto
Rossi Mario329
Rossi Mario149
Ferrari Luca329
Greco Sara149
Conti Marco199

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)
Risultato: i clienti che hanno ordinato almeno una volta
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
Risultato: i prodotti che non sono mai stati ordinati
prodottocategoria
Webcam HDElettronica

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' )
Risultato: i prodotti mai comprati da un cliente di 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)
Risultato: i reparti con stipendio medio sopra la media aziendale
repartostipendio_medio
Vendite51000
Ricerca64000

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 )
Risultato: per ogni cliente, il suo ordine più costoso
clienteordineimporto
Rossi Mario1329
Bianchi Giulia389
Ferrari Luca4329
Greco Sara6149
Conti Marco7199
Bruno Elena835
De Luca Chiara979

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
Risultato: i prodotti comprati da almeno due clienti diversi
prodottoclienti
Tastiera meccanica2
Monitor 27 pollici2
Sedia da ufficio2

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é.

Le due forme brevi
SELECT … FROM reparti r
JOIN dipendenti d USING (id)
La colonna 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.
SELECT … FROM reparti r
NATURAL JOIN dipendenti d
Unisce su tutte le colonne omonime, senza dire quali. Qui unirebbe su 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 ON leggi la condizione; con USING o NATURAL devi andare a controllare il modello per sapere quali colonne sono state usate.
  • Un NATURAL JOIN può cambiare significato da solo. Se domani aggiungi una colonna sede sia a reparti sia a dipendenti, la query comincia a unire anche su quella, senza che tu abbia scritto niente.
  • Non puoi scegliere la direzione. Con NATURAL non esiste la variante LEFT: 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'
Risultato sbagliato: il filtro ha eliminato i clienti senza ordini
nomeimportodata_ordine
Greco Sara1492026-03-02
Conti Marco1992026-03-08
Bruno Elena352026-03-15
De Luca Chiara792026-03-21
Rossi Mario1492026-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'
Risultato corretto: gli ordini da marzo, più i clienti che non ne hanno
nomeimportodata_ordine
Rossi Mario1492026-04-02
Bianchi GiuliaNULLNULL
Ferrari LucaNULLNULL
Greco Sara1492026-03-02
Conti Marco1992026-03-08
Bruno Elena352026-03-15
De Luca Chiara792026-03-21
Gallo PietroNULLNULL

🎯 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.

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.