SQL Server, dalle fondamenta Lezione 9 / 40

JOIN: l'unico diagramma che ti serve

INNER, LEFT, RIGHT, FULL, CROSS. Eresia del diagramma di Venn. Semi-join, anti-join, e la differenza tra un predicato di JOIN e un filtro WHERE.

Se c’è un argomento in SQL che separa “qualcuno che conosce un po’ di SQL” da “qualcuno che sa effettivamente usare un database relazionale,” sono i join. I join sono il motivo per cui esiste la R in RDBMS. Sono come combini Sales.Orders con Sales.Customer per vedere quale cliente ha piazzato ciascun ordine, come colleghi Inventory.Stock a Catalog.Product per vedere cosa c’è davvero in magazzino, come leghi un Support.Ticket allo Shipping.Shipment che ha portato qualcuno a mandare una mail.

Il business di Runehold gira sui join. Marketing vuole “i clienti che spendono di più in Italia.” Operations vuole “ordini spediti in ritardo questa settimana.” Finance vuole “ricavi per aliquota IVA per paese il trimestre scorso.” Nessuna di queste è una domanda single-tabella. Sono tutte join.

L’eresia del diagramma di Venn

Hai visto i diagrammi di Venn. Due cerchi sovrapposti. “INNER JOIN è l’intersezione, LEFT JOIN è il cerchio di sinistra più la sovrapposizione.” I diagrammi sono ampiamente condivisi, in gran parte sbagliati, e ti spiegherò il vero modello mentale perché rende banali i join più difficili in seguito.

La verità: i join producono una riga per ogni combinazione valida di righe dalle tabelle in input. A volte è l’intersezione di insiemi; a volte è una moltiplicazione. I diagrammi di Venn implicano un pensiero per insiemi quando i join sono in realtà combinatori per righe.

Tieni l’immagine di Venn come mnemonico approssimativo se ti aiuta. Ma quando succede qualcosa di strano con i join, ricorri al modello delle combinazioni di righe. Mostrerò degli esempi.

INNER JOIN: righe che corrispondono da entrambi i lati

INNER JOIN (o solo JOIN — la stessa cosa) ritorna righe che hanno una corrispondenza in entrambe le tabelle in base alla condizione di join.

-- Ordini con il nome e il paese del cliente
SELECT o.OrderId,
       o.Total,
       c.Name,
       c.Country
FROM Sales.Orders   AS o
INNER JOIN Sales.Customer AS c
    ON c.CustomerId = o.CustomerId;

Se un ordine ha un CustomerId ma quel cliente non esiste in Sales.Customer (raro, per via della foreign key), l’ordine è escluso. Se un cliente non ha ordini, è escluso. Solo le coppie in cui entrambi i lati corrispondono compaiono.

ON <condizione> è come dici al motore cosa significa “corrispondenza”. Di solito è un’uguaglianza tra chiavi ma può essere qualsiasi espressione booleana. ON c.CustomerId = o.CustomerId è il pattern standard “collega sulla foreign key”.

LEFT JOIN: tutto da sinistra, più le corrispondenze da destra

Spesso scritto come LEFT OUTER JOIN, ma OUTER è opzionale e tutti lo saltano.

-- Ogni cliente, con i suoi ordini se ne ha
SELECT c.CustomerId,
       c.Name,
       o.OrderId,
       o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId;

I clienti senza ordini compaiono comunque nel risultato. Le colonne da o sono NULL per quelle righe. È cruciale per domande tipo “quali clienti non hanno mai ordinato?” e “quali prodotti non hanno mai venduto?”:

-- Clienti che non hanno mai ordinato
SELECT c.CustomerId, c.Name
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
WHERE o.OrderId IS NULL;

Il filtro o.OrderId IS NULL mantiene solo le righe in cui il LEFT JOIN non è riuscito a trovare una corrispondenza. Questo pattern si chiama anti-join, ed è come rispondi a ogni domanda “X senza Y”.

RIGHT JOIN: nessuno lo scrive

RIGHT JOIN è uno specchio di LEFT JOIN — tutto dal lato destro, più le corrispondenze da sinistra. Esiste per completezza.

In pratica, non scrivi mai RIGHT JOIN in nuovo codice. Ogni volta che sei tentato, gira semplicemente l’ordine delle tabelle e usa LEFT JOIN — molto più facile da leggere:

-- Questi sono equivalenti:
SELECT ...
FROM A LEFT JOIN B ON ...

SELECT ...
FROM B RIGHT JOIN A ON ...

La consistenza vince. Stai con LEFT.

FULL OUTER JOIN: entrambi i lati, più le non corrispondenze

Ritorna tutto da entrambi i lati. Le righe che corrispondono su entrambi i lati sono combinate. Le righe che non corrispondono su un lato hanno NULL per il lato mancante.

-- Prodotti con vendite questo mese, più prodotti che esistono ma non hanno venduto,
-- più vendite che fanno riferimento a prodotti che non vendiamo più.
SELECT p.Sku,
       p.Name,
       s.OrderId,
       s.Quantity
FROM Catalog.Product AS p
FULL OUTER JOIN Sales.OrderLine AS s
    ON s.ProductId = p.ProductId
WHERE s.OrderDate >= '2026-04-01' OR s.OrderDate IS NULL;

FULL OUTER è davvero utile quando cerchi “non corrispondenze su un qualsiasi lato”. Lo uso circa una volta al mese per i report di riconciliazione.

CROSS JOIN: tutto per tutto

SELECT c.Name, p.Name
FROM Sales.Customer AS c
CROSS JOIN Catalog.Product AS p;

Ogni cliente accoppiato con ogni prodotto. Se hai 50 clienti e 8.000 prodotti, ottieni 400.000 righe. Utile per:

  • Riempire una dimensione data (ogni data × ogni paese, per griglie di reportistica senza buchi).
  • Costruire matrici (ogni dipendente × ogni training obbligatorio).
  • Generare dati di test.

Usato raramente nelle query di produzione. Usato costantemente nella generazione di report.

Non c’è clausola ON. Qualsiasi filtro che vuoi va in WHERE.

Predicati di JOIN contro filtri WHERE

Sottile e importante. C’è una differenza tra questi due:

-- A: filtro nella clausola ON
SELECT c.Name, o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
   AND o.CountryCode = 'IT';

-- B: filtro nella clausola WHERE
SELECT c.Name, o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
WHERE o.CountryCode = 'IT';

Per un INNER JOIN, sono equivalenti. Per un LEFT JOIN — che è quello sopra — non lo sono.

Query A: “ogni cliente, con i suoi ordini IT se ne ha; i clienti senza ordini IT compaiono comunque, con colonne NULL da o.”

Query B: “ogni cliente, con tutti i suoi ordini, poi filtra le righe in cui o.CountryCode non è 'IT'.” Il WHERE o.CountryCode = 'IT' elimina le righe in cui o.CountryCode è NULL (dal null-fill dell’outer join), quindi i clienti senza ordini IT spariscono completamente. Hai effettivamente trasformato il LEFT JOIN di nuovo in un INNER JOIN.

La regola: i filtri sulla tabella di destra vanno nella clausola ON di un LEFT JOIN. I filtri sulla tabella di sinistra vanno nella clausola WHERE. I filtri che si applicano a entrambe le tabelle o che non si curano del tipo di join vanno in WHERE.

Questo è il singolo bug di join più comune nel codice reale. Sistema il modello mentale una volta e smette.

Join multi-tabella

Puoi concatenare join attraverso molte tabelle. La query “report spedizioni” di Runehold:

SELECT o.OrderId,
       c.Name                   AS CustomerName,
       c.Country                 AS CustomerCountry,
       s.TrackingNumber,
       s.ShippedAt,
       carr.Name                 AS Carrier
FROM Sales.Orders           AS o
JOIN Sales.Customer         AS c   ON c.CustomerId = o.CustomerId
JOIN Shipping.Shipment      AS s   ON s.OrderId    = o.OrderId
JOIN Shipping.Carrier       AS carr ON carr.CarrierId = s.CarrierId
WHERE o.OrderDate >= '2026-04-01'
  AND o.OrderDate  < '2026-04-08'
ORDER BY s.ShippedAt DESC;

Ogni nuovo JOIN aggiunge più filtraggio (per INNER) o più opzionalità (per OUTER). Gli alias sono obbligatori qui — senza, la query è illeggibile. Scegli alias brevi e memorabili. Non usare t1, t2, t3. Usa o, c, s, carr.

Self-join

Una tabella unita a se stessa — comune per query gerarchiche e pattern “confronta una riga con un’altra riga della stessa tabella”.

I dipendenti di Runehold hanno manager che sono anche loro dipendenti:

SELECT e.FullName    AS Employee,
       e.Team        AS Team,
       m.FullName    AS Manager
FROM HR.Employee AS e
LEFT JOIN HR.Employee AS m
    ON m.EmployeeId = e.ManagerId;

La stessa tabella, con alias e (employee) e m (manager), unita sulla foreign key ManagerId. Questo è self-joining, e va benissimo. Qualsiasi pattern basato sulle righe — “trova clienti che hanno un altro cliente nella stessa città con LTV più alto” — è un self-join.

EXISTS e NOT EXISTS: semi-join e anti-join

A volte non vuoi le colonne unite, solo l’informazione di appartenenza: “esiste almeno un ordine per questo cliente?”

-- Clienti con almeno un ordine
SELECT c.CustomerId, c.Name
FROM Sales.Customer AS c
WHERE EXISTS (
    SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);

-- Clienti SENZA ordini
SELECT c.CustomerId, c.Name
FROM Sales.Customer AS c
WHERE NOT EXISTS (
    SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);

EXISTS è un semi-join: tiene le righe da sinistra dove esiste una corrispondenza a destra, ma non duplica le righe se esistono più corrispondenze. Confrontato con JOIN:

  • JOIN può moltiplicare le righe: un cliente con cinque ordini → cinque righe.
  • EXISTS mantiene una riga per corrispondenza sul lato sinistro.

NOT EXISTS è l’anti-join — mantiene le righe senza corrispondenza. Più sicuro di NOT IN quando c’è di mezzo NULL (l’abbiamo coperto nella lezione 7).

Regola: usa EXISTS / NOT EXISTS quando hai bisogno solo dell’appartenenza, non dei dati dall’altro lato. Più pulito, di solito più veloce, e non suscettibile al bug “ho moltiplicato accidentalmente le mie righe”.

Esegui questo sulla tua macchina

USE Runehold;
GO

-- Estendiamo i nostri dati con un nuovo Customer senza ordini
INSERT INTO Sales.Customer (Name, Country)
VALUES ('Zero-order Zoe', 'ES');

-- Query 1: INNER JOIN — ordini + info cliente, solo righe corrispondenti
SELECT o.OrderId,
       c.Name,
       o.Total,
       o.CountryCode
FROM Sales.Orders  AS o
JOIN Sales.Customer AS c
    ON c.CustomerId = o.CustomerId
ORDER BY o.OrderId;

-- Query 2: LEFT JOIN — ogni cliente, con ordini o NULL
SELECT c.CustomerId,
       c.Name,
       c.Country,
       o.OrderId,
       o.Total
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
ORDER BY c.CustomerId;

-- Query 3: l'anti-join "clienti senza ordini"
SELECT c.CustomerId, c.Name, c.Country
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
WHERE o.OrderId IS NULL;

-- Query 4: la stessa, ma con NOT EXISTS (equivalente, spesso più chiaro)
SELECT c.CustomerId, c.Name, c.Country
FROM Sales.Customer AS c
WHERE NOT EXISTS (
    SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);

-- Query 5: demo ON contro WHERE — individua la differenza
-- "A" mantiene clienti senza ordini italiani, con NULL
SELECT c.Name, o.OrderId, o.CountryCode
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
   AND o.CountryCode = 'IT'
ORDER BY c.CustomerId;

-- "B" diventa effettivamente un INNER JOIN, il filtro elimina le non corrispondenze
SELECT c.Name, o.OrderId, o.CountryCode
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o
    ON o.CustomerId = c.CustomerId
WHERE o.CountryCode = 'IT'
ORDER BY c.CustomerId;

Esegui le query 5A e 5B. Conta le righe. Convinciti del perché differiscono. Questa è la trappola esatta che incontrerai nel codice reale.

Prossima lezione: GROUP BY, HAVING, e gli aggregati — costruendo sui join di questa lezione per scrivere finalmente vere query di business come “ricavi totali per paese” e “valore medio dell’ordine per fascia cliente”.

Cerca