Lezione breve oggi. Solo una keyword, un bug, una regola.
Il team finance di Runehold ha due tabelle: Sales.Orders per il canale online e Retail.Sale per i negozi fisici. Vogliono una vista unificata per il reporting del fatturato. La query? Due select combinate con UNION. Solo che… UNION è più lento del necessario nel 99% dei casi, e metà delle query in produzione che usano UNION dovrebbero in realtà usare UNION ALL.
Sistemiamo la cosa.
I quattro operatori insiemistici
T-SQL ha quattro operatori che combinano result set:
UNION— righe di A + righe di B, con i duplicati rimossiUNION ALL— righe di A + righe di B, conservando i duplicatiINTERSECT— righe che appaiono sia in A sia in BEXCEPT— righe in A che non appaiono in B
Tutti e quattro richiedono:
- Entrambi i lati hanno lo stesso numero di colonne
- I tipi delle colonne sono compatibili (altrimenti SQL Server tenta una conversione implicita, che può rompere la SARGability)
- I nomi delle colonne nel risultato finale vengono dalla prima query
UNION vs UNION ALL
-- UNION: unisce + rimuove duplicati
SELECT ProductId, Quantity FROM Sales.OrderLine
UNION
SELECT ProductId, Quantity FROM Retail.SaleLine;
-- UNION ALL: unisce, mantiene i duplicati
SELECT ProductId, Quantity FROM Sales.OrderLine
UNION ALL
SELECT ProductId, Quantity FROM Retail.SaleLine;
L’unica differenza: UNION rimuove i duplicati, UNION ALL no.
Per rimuovere i duplicati, SQL Server deve fare lavoro extra: ordinare o fare hash di ogni riga combinata, confrontare e scartare i duplicati. È misurabile su dati piccoli e doloroso su dati grandi.
La regola:
- Usa
UNION ALLa meno che tu non abbia un motivo specifico per deduplicare. - Usa
UNIONsolo quando sai che ci possono essere duplicati e li vuoi via.
Nella maggior parte dei casi in cui combini dati partizionati — ordini online + ordini retail, log archiviati + log live, dati del tenant A + dati del tenant B — non ci sono duplicati possibili per costruzione. UNION ALL è la scelta giusta.
Scrivere UNION “per sicurezza” è come ordinare un array già ordinato “per sicurezza”. Non è gratis.
L’unico posto in cui ha senso scrivere UNION
Il marketing di Runehold ha una mailing list aziendale costruita da tre fonti: clienti che hanno dato il consenso, dipendenti, partner. Alcune persone si sovrappongono — un dipendente che è anche cliente.
-- Combina tre liste, rimuovi i duplicati
SELECT Email FROM Sales.Customer WHERE EmailOptIn = 1
UNION
SELECT Email FROM HR.Employee
UNION
SELECT Email FROM Partners.Contact;
Questo è un UNION legittimo. L’output atteso è una lista deduplicata, e i duplicati tra le fonti sono genuinamente possibili. In questo caso UNION ALL manderebbe alla stessa persona tre email. UNION è la scelta giusta.
In caso di dubbio: questa riga può legittimamente esistere in entrambi gli input? Se sì, devi decidere cosa fare con il duplicato. Se no, UNION ALL è più sicuro e più veloce.
INTERSECT ed EXCEPT
Meno usati ma bellissimi quando calzano.
INTERSECT — cosa c’è in entrambi?
-- Clienti che hanno ordinato online E sono entrati in un negozio
SELECT CustomerId FROM Sales.Orders
INTERSECT
SELECT CustomerId FROM Retail.Sale;
Restituisce i CustomerId che appaiono in entrambi i result set. Equivalente a una inner join / EXISTS ma spesso molto più leggibile per le domande di “sovrapposizione”.
EXCEPT — in A ma non in B?
-- Clienti che hanno ordinato online ma non sono mai entrati in un negozio
SELECT CustomerId FROM Sales.Orders
EXCEPT
SELECT CustomerId FROM Retail.Sale;
Restituisce le righe nella prima query che non appaiono nella seconda. Identico nell’effetto a NOT EXISTS.
Sia INTERSECT sia EXCEPT deduplicano, come UNION. Per query a colonna singola sono puliti e ovvi. Per più colonne può diventare confuso — ogni colonna partecipa al confronto “questa riga corrisponde?”.
Un uso classico di EXCEPT: la riconciliazione di dati. “Quali righe ci sono nella tabella di staging che non sono in produzione?” Un modo veloce e ovvio di confrontare due result set.
ORDER BY va solo nell’ULTIMA query
-- SBAGLIATO: ORDER BY su una query intermedia
SELECT Email FROM Sales.Customer ORDER BY Email -- errore
UNION
SELECT Email FROM HR.Employee;
-- GIUSTO: ORDER BY dopo tutto
SELECT Email FROM Sales.Customer
UNION
SELECT Email FROM HR.Employee
ORDER BY Email;
Un solo ORDER BY ammesso, alla fine. Si applica al risultato combinato.
Se hai davvero bisogno di ordinare dentro un ramo (per esempio, vuoi TOP (10) da ciascuna fonte combinati), usa le subquery:
SELECT * FROM (
SELECT TOP (10) Email, 'Customer' AS source
FROM Sales.Customer
ORDER BY CreatedAt DESC
) AS c
UNION ALL
SELECT * FROM (
SELECT TOP (10) Email, 'Employee' AS source
FROM HR.Employee
ORDER BY HireDate DESC
) AS e
ORDER BY source, Email;
Ogni subquery fa il proprio TOP + ORDER BY; l’UNION ALL esterno combina i due, e l’ORDER BY finale ordina il risultato combinato.
Nomi e tipi delle colonne
I nomi delle colonne vengono dalla prima query. I tipi devono essere compatibili o SQL Server fa una conversione implicita.
SELECT CustomerId AS id, 'Sales' AS source FROM Sales.Orders
UNION ALL
SELECT EmployeeId, 'HR' FROM HR.Employee;
-- Colonne risultato: id, source
Se i tipi delle colonne non corrispondono, SQL Server proverà a convertire a un tipo comune, o fallirà con un errore esplicito “tipi incompatibili”. Best practice: fai il cast esplicito quando non è ovvio:
SELECT CAST(CustomerId AS INT) AS id, 'Sales' AS source FROM Sales.Orders
UNION ALL
SELECT CAST(EmployeeId AS INT), 'HR' FROM HR.Employee;
Ti risparmia un errore confuso in produzione quando qualcuno cambia un tipo a valle.
Etichettare la fonte: un pattern comune
Quando combini dati da più fonti, aggiungi una colonna letterale che identifichi da dove viene ogni riga:
SELECT 'online' AS channel,
OrderDate, Total, CustomerId
FROM Sales.Orders
UNION ALL
SELECT 'retail',
SaleDate, Total, CustomerId
FROM Retail.Sale;
La dashboard di fatturato del finance: una tabella, una serie temporale, etichettata per canale. Facile da raggruppare, facile da grafiare, facile da debuggare.
Esegui questo sulla tua macchina
USE Runehold;
GO
-- Setup di un piccolo Retail.Sale per la demo
IF SCHEMA_ID('Retail') IS NULL EXEC('CREATE SCHEMA Retail AUTHORIZATION dbo');
IF OBJECT_ID('Retail.Sale', 'U') IS NULL
CREATE TABLE Retail.Sale (
SaleId BIGINT IDENTITY(1,1) PRIMARY KEY,
StoreCity NVARCHAR(50) NOT NULL,
SaleDate DATETIME2(0) NOT NULL,
Total DECIMAL(19,4) NOT NULL,
CustomerId INT NULL -- alcune vendite in negozio sono anonime
);
INSERT INTO Retail.Sale (StoreCity, SaleDate, Total, CustomerId)
VALUES ('Amsterdam', '2026-03-20', 45.00, 1),
('Milan', '2026-03-21', 62.00, 2),
('Milan', '2026-03-21', 18.50, NULL),
('Berlin', '2026-03-22', 99.00, 3);
-- Query 1: vista fatturato unificata con tag canale
SELECT 'online' AS channel,
OrderDate AS tx_date,
Total,
CustomerId
FROM Sales.Orders
UNION ALL
SELECT 'retail',
SaleDate,
Total,
CustomerId
FROM Retail.Sale
ORDER BY tx_date DESC;
-- Query 2: totali combinati per canale
WITH combined AS (
SELECT 'online' AS channel, Total FROM Sales.Orders
UNION ALL
SELECT 'retail', Total FROM Retail.Sale
)
SELECT channel, COUNT(*) AS tx_count, SUM(Total) AS revenue
FROM combined
GROUP BY channel;
-- Query 3: INTERSECT — clienti attivi su entrambi i canali
SELECT CustomerId FROM Sales.Orders WHERE CustomerId IS NOT NULL
INTERSECT
SELECT CustomerId FROM Retail.Sale WHERE CustomerId IS NOT NULL;
-- Query 4: EXCEPT — clienti che hanno comprato online ma mai in negozio
SELECT CustomerId FROM Sales.Orders WHERE CustomerId IS NOT NULL
EXCEPT
SELECT CustomerId FROM Retail.Sale WHERE CustomerId IS NOT NULL;
-- Query 5: confronto di performance — cronometra questa contro UNION ALL su un set grande
-- (qui dati piccoli, ma il principio scala)
SET STATISTICS TIME ON;
SELECT OrderDate AS d FROM Sales.Orders
UNION
SELECT SaleDate FROM Retail.Sale; -- dedup implicita
SELECT OrderDate AS d FROM Sales.Orders
UNION ALL
SELECT SaleDate FROM Retail.Sale; -- più veloce
SET STATISTICS TIME OFF;
Esegui le tempistiche su un dataset più grande e vedrai il costo. Su una tabella con milioni di righe, UNION vs UNION ALL è la differenza tra una query di 4 secondi e una di 400 ms.
Prossima lezione: funzioni stringa. STRING_AGG, STRING_SPLIT, TRIM, CONCAT_WS, e la morte di metà delle funzioni CLR che le vecchie shop continuano a portarsi dietro.