Ogni business gira sui KPI — Key Performance Indicators. Il team dirigenziale di Runehold riceve una dashboard del lunedì mattina con “ricavi per paese la settimana scorsa,” “valore medio ordine per canale,” “top 10 SKU per margine lordo,” “conteggio clienti per fonte di acquisizione.” Sono tutte query GROUP BY.
Se capisci GROUP BY, puoi trasformare 10 milioni di righe di ordini in un riepilogo dirigenziale di 20 righe. Se non lo capisci, passi ore a costruire cose in Excel che avrebbero dovuto richiedere 30 secondi in SQL. La lezione di oggi insegna il pattern.
Cosa significa aggregazione
Una funzione di aggregazione prende molte righe e ritorna un valore. I cinque grandi:
SUM(expr)— totaleCOUNT(*)/COUNT(expr)— conteggio righe (abbiamo coperto la differenza con NULL nella lezione 7)AVG(expr)— media aritmeticaMIN(expr)/MAX(expr)— più piccolo / più grande
Più:
STDEV(expr),VAR(expr)— deviazione standard, varianza. Rare ma utili.STRING_AGG(expr, separator)— concatena stringhe. La copriremo nella lezione 14.
Senza un GROUP BY, le funzioni di aggregazione collassano l’intero result set a una riga:
SELECT SUM(Total) AS total_revenue,
AVG(Total) AS average_order_value,
COUNT(*) AS order_count,
MIN(OrderDate) AS first_order_ever,
MAX(OrderDate) AS most_recent_order
FROM Sales.Orders;
Quella query risponde a “cosa sappiamo in aggregato di tutti gli ordini?” Una riga in output. Pulito.
Aggiungi un GROUP BY e ottieni una riga per gruppo:
SELECT CountryCode,
SUM(Total) AS revenue,
COUNT(*) AS orders,
AVG(Total) AS avg_order_value
FROM Sales.Orders
GROUP BY CountryCode
ORDER BY revenue DESC;
Quello è “ricavi, conteggio ordini e valore medio dell’ordine, suddivisi per paese.” Quattro o cinque righe in output. Marketing adora questa query. È così che viene calcolato il riquadro “ricavi per paese” della tua dashboard.
La regola del GROUP BY
Ogni colonna nella tua lista SELECT deve essere o:
- Dentro una funzione di aggregazione, oppure
- Elencata nella clausola
GROUP BY.
Perché? Perché se provi a proiettare una colonna che non è in GROUP BY e non è aggregata, il motore non ha idea di quale dei tanti valori delle righe raggruppate mostrarti. SQL Server ritorna un errore: “Column ‘X’ is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.”
-- ERRORE
SELECT CountryCode, Name, SUM(Total)
FROM Sales.Orders
JOIN Sales.Customer ON ...
GROUP BY CountryCode;
-- Name non è raggruppata e non è aggregata.
-- FIX 1: aggiungila al gruppo
SELECT CountryCode, Name, SUM(Total)
FROM Sales.Orders
JOIN Sales.Customer ON ...
GROUP BY CountryCode, Name;
-- FIX 2: aggregala
SELECT CountryCode, MIN(Name), SUM(Total)
FROM Sales.Orders
JOIN Sales.Customer ON ...
GROUP BY CountryCode;
MySQL e Postgres in modalità “loose” ti lasciano violare questa regola; SQL Server no. È una feature. Il group-by “loose” si rompe sempre in modi sottili quando i dati cambiano. SQL Server ti salva da te stesso.
WHERE contro HAVING
WHERE filtra le righe prima del raggruppamento. HAVING filtra i gruppi dopo l’aggregazione. Non sono intercambiabili:
-- Paesi con più di 5 ordini E ricavi totali sopra i 500 EUR
SELECT CountryCode,
COUNT(*) AS orders,
SUM(Total) AS revenue
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01' -- filtro per riga: prima del raggruppamento
GROUP BY CountryCode
HAVING COUNT(*) > 5 -- filtro di gruppo: dopo l'aggregazione
AND SUM(Total) > 500;
Regola pratica:
- Se il tuo filtro può essere valutato su una singola riga della tabella sorgente, usa
WHERE. - Se il tuo filtro ha bisogno di un aggregato (
COUNT(*),SUM(...), ecc.), deve essereHAVING.
Ogni volta che vedi una condizione che potrebbe andare in WHERE, mettila in WHERE — il motore filtra prima, il che è più economico. Solo i filtri basati su aggregati appartengono a HAVING.
DISTINCT dentro gli aggregati
Un altro trucco utile. Puoi avvolgere DISTINCT attorno all’argomento di un aggregato:
-- Quanti clienti unici hanno piazzato un ordine il mese scorso?
SELECT COUNT(DISTINCT CustomerId) AS unique_customers
FROM Sales.Orders
WHERE OrderDate >= '2026-03-01' AND OrderDate < '2026-04-01';
-- Valore medio dell'ordine per SKU unica per paese
-- "In media, attraverso i prodotti che abbiamo venduto in ogni paese, qual era il ricavo per SKU?"
SELECT CountryCode,
SUM(Total) / COUNT(DISTINCT ProductId) AS avg_rev_per_sku
FROM Sales.OrderLine
JOIN Sales.Orders ON ...
GROUP BY CountryCode;
COUNT(DISTINCT customer_id) è il KPI che la maggior parte dei team chiama “clienti attivi” o “MAU/DAU” (monthly/daily active users). Diverso da COUNT(*) quando un qualsiasi cliente ha più ordini.
GROUP BY con più colonne
Raggruppa per quante colonne vuoi; ottieni una riga per ogni combinazione unica:
-- Ricavi per paese per mese
SELECT CountryCode,
YEAR(OrderDate) AS yr,
MONTH(OrderDate) AS mo,
SUM(Total) AS revenue,
COUNT(*) AS orders
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01'
GROUP BY CountryCode, YEAR(OrderDate), MONTH(OrderDate)
ORDER BY yr, mo, revenue DESC;
Nota: usare YEAR(OrderDate) e MONTH(OrderDate) in GROUP BY va bene, ma impedisce a un indice su OrderDate di essere usato per range seek. Alternativa comune: raggruppare per un’espressione date-truncated, oppure pre-calcolare una colonna YearMonth nello schema. Faremo questo refactor per bene nella lezione 15.
I KPI di business, in SQL
Ecco alcuni KPI reali che i team finance e marketing di Runehold lanciano, scritti per intero. Sono utili come riferimento quando ti viene chiesto “puoi farmi avere…?” da qualcuno non tecnico.
Ricavi totali
SELECT SUM(Total) AS gmv_eur -- GMV = Gross Merchandise Value
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01'
AND OrderDate < '2026-04-01';
Average Order Value (AOV)
SELECT SUM(Total) * 1.0 / COUNT(*) AS aov_eur
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01' AND OrderDate < '2026-04-01';
Il * 1.0 forza la divisione a ritornare un decimale invece della divisione intera. Piccolo dettaglio, grande differenza: 15 / 7 è 2; 15 * 1.0 / 7 è 2.142857....
Tasso di conversione
“Sessioni che hanno portato a un acquisto, divise per le sessioni totali.”
-- Schema sketch: Marketing.Session traccia le visite, ha il bit HasPurchase
SELECT
CountryCode,
COUNT(*) AS sessions,
SUM(CAST(HasPurchase AS INT)) AS purchasing_sessions,
SUM(CAST(HasPurchase AS INT)) * 100.0 / COUNT(*) AS conversion_rate_pct
FROM Marketing.Session
WHERE SessionStart >= '2026-03-01'
GROUP BY CountryCode
ORDER BY conversion_rate_pct DESC;
SUM(CAST(HasPurchase AS INT)) è il modo idiomatico di contare le righe che soddisfano una condizione quando vuoi anche il conteggio totale delle righe nello stesso aggregato. COUNT(CASE WHEN HasPurchase = 1 THEN 1 END) è equivalente e più leggibile nei casi complessi.
Conteggio clienti per paese — “DAU proxy”
SELECT CountryCode,
COUNT(DISTINCT CustomerId) AS unique_customers_last_30d
FROM Sales.Orders
WHERE OrderDate >= DATEADD(DAY, -30, GETDATE())
GROUP BY CountryCode
ORDER BY unique_customers_last_30d DESC;
Customer Acquisition Cost (CAC), un KPI a due passi
CAC = spesa marketing per un periodo / nuovi clienti acquisiti in quel periodo.
DECLARE @start DATE = '2026-03-01';
DECLARE @end DATE = '2026-04-01';
WITH spend AS (
SELECT Channel, SUM(Amount) AS total_spend
FROM Marketing.Spend
WHERE SpendDate >= @start AND SpendDate < @end
GROUP BY Channel
),
new_customers AS (
SELECT AcquisitionChannel AS Channel, COUNT(*) AS new_count
FROM Sales.Customer
WHERE CreatedAt >= @start AND CreatedAt < @end
GROUP BY AcquisitionChannel
)
SELECT
s.Channel,
s.total_spend,
n.new_count,
s.total_spend / NULLIF(n.new_count, 0) AS cac_eur
FROM spend AS s
LEFT JOIN new_customers AS n ON n.Channel = s.Channel
ORDER BY cac_eur DESC;
NULLIF(n.new_count, 0) previene la divisione per zero. Se un canale ha speso soldi e non ha acquisito nessuno, altrimenti otterresti un errore T-SQL; NULLIF trasforma lo zero in NULL e X / NULL è NULL. La riga compare con CAC NULL, che è corretto — è indefinito.
Lifetime Value (LTV), forma base
LTV per cliente = somma di tutti i totali dei suoi ordini.
SELECT c.CustomerId,
c.Name,
c.Country,
COALESCE(SUM(o.Total), 0) AS lifetime_value_eur,
COUNT(o.OrderId) AS order_count
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o ON o.CustomerId = c.CustomerId
GROUP BY c.CustomerId, c.Name, c.Country
ORDER BY lifetime_value_eur DESC;
Il pattern LEFT JOIN + COALESCE(SUM(...), 0) gestisce i clienti che non hanno mai ordinato (ottengono LTV = 0 invece di NULL). La leadership vuole sempre i clienti con LTV zero nei loro export — sono la più grande popolazione non sfruttata.
ROLLUP, CUBE, GROUPING SETS — subtotali senza dolore
Vuoi un report che mostri ricavi per paese e un totale generale nello stesso risultato? WITH ROLLUP:
SELECT CountryCode,
SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode
WITH ROLLUP;
Produce una riga per paese più una riga extra in cui CountryCode è NULL e revenue è il totale generale.
CUBE ti dà tutte le combinazioni di subtotali (ogni colonna × ogni altra). GROUPING SETS ti dà controllo esplicito:
SELECT CountryCode, YEAR(OrderDate) AS yr, SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY GROUPING SETS (
(CountryCode, YEAR(OrderDate)), -- per paese per anno
(CountryCode), -- per paese (tutti gli anni)
(YEAR(OrderDate)), -- per anno (tutti i paesi)
() -- totale generale
);
Sono inestimabili per i report in stile pivot. Usa GROUPING() per distinguere tra “vero NULL” e “null da un rollup”:
SELECT CASE WHEN GROUPING(CountryCode) = 1 THEN 'TOTAL'
ELSE CountryCode
END AS label,
SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode WITH ROLLUP;
Esegui questo sulla tua macchina
USE Runehold;
GO
-- Ricavi per paese il mese scorso
SELECT CountryCode,
SUM(Total) AS revenue_eur,
COUNT(*) AS order_count,
AVG(Total) AS aov_eur,
MIN(Total) AS smallest_order,
MAX(Total) AS biggest_order
FROM Sales.Orders
WHERE OrderDate >= DATEADD(MONTH, -1, GETDATE())
GROUP BY CountryCode
ORDER BY revenue_eur DESC;
-- Con HAVING: solo paesi con 2+ ordini
SELECT CountryCode,
COUNT(*) AS orders,
SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode
HAVING COUNT(*) >= 2
ORDER BY revenue DESC;
-- Clienti attivi (unici) per paese
SELECT CountryCode,
COUNT(DISTINCT CustomerId) AS unique_customers,
COUNT(*) AS orders,
SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode;
-- LTV cliente classificato
SELECT c.CustomerId,
c.Name,
c.Country,
COALESCE(SUM(o.Total), 0) AS ltv_eur,
COUNT(o.OrderId) AS orders
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o ON o.CustomerId = c.CustomerId
GROUP BY c.CustomerId, c.Name, c.Country
ORDER BY ltv_eur DESC;
-- ROLLUP per il totale generale
SELECT CASE WHEN GROUPING(CountryCode) = 1 THEN 'ALL COUNTRIES'
ELSE CountryCode
END AS country_label,
SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode WITH ROLLUP
ORDER BY GROUPING(CountryCode), revenue DESC;
Nota come ogni query corrisponda a una domanda che una persona di business porrebbe davvero: “qual è stato il nostro ricavo il mese scorso?”, “quali paesi hanno clienti che ritornano?”, “chi sono i nostri migliori clienti?”. Quello è l’obiettivo. Scrivere queste nel sonno è ciò che separa un data engineer da qualcuno che ogni tanto lancia query SELECT.
Prossima lezione: subquery contro CTE. Quando usare quale, come scriverne di leggibili, e perché una CTE ricorsiva non è poi così spaventosa come sembra.