ORDER BY sembra innocuo. Scegli una colonna, scegli una direzione, fatto. Poi scrivi un endpoint di paginazione, lo spedisci, e una settimana dopo il team di customer success chiede perché lo stesso ordine continua a comparire sia in pagina 1 sia in pagina 2 della dashboard admin. Niente è rotto. L’ordinamento semplicemente non fa quello che pensi quando ci sono dei pareggi.
Questa lezione parla di ORDER BY, TOP e OFFSET ... FETCH — come interagiscono, quando mordono, e l’unico pattern che ogni sviluppatore SQL sbaglia la prima volta che ha bisogno di “top N righe per gruppo”.
ORDER BY: le basi
ORDER BY ordina il result set. Crescente di default, decrescente con DESC:
SELECT OrderId, Total, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC; -- prima i più recenti
Più colonne, applicate da sinistra a destra:
SELECT OrderId, CountryCode, Total, OrderDate
FROM Sales.Orders
ORDER BY CountryCode ASC, -- raggruppa per paese
Total DESC; -- dentro ogni paese, prima il più grande
Puoi ordinare per qualsiasi colonna delle tabelle sorgente — anche se non è nella lista SELECT — perché ORDER BY gira dopo SELECT nell’ordine logico (lezione 6) e ha accesso a tutto quello che FROM ha prodotto.
ORDER BY nelle subquery: per lo più inutile
Un errore molto comune in SQL Server: scrivere ORDER BY in una subquery, vista o CTE e aspettarsi che la query esterna preservi l’ordine.
-- Questo NON garantisce che il SELECT esterno ritorni le righe in ordine di data.
SELECT *
FROM (
SELECT OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC -- ← inutile qui
) AS o;
Lo standard SQL dice che un’espressione di tabella non ha un ordine intrinseco. L’ordinamento dentro la subquery può essere ignorato. SQL Server di solito te lo lascia fare solo combinato con TOP, e l’ordinamento della query esterna resta comunque indefinito. Metti ORDER BY solo sulla query più esterna che andrai a consumare.
Se stai scrivendo una vista o una CTE, non disturbarti a ordinare al suo interno. Ordina sul consumatore finale.
Il problema dei “pareggi” di cui nessuno ti avverte
Ecco una query che sembra ovviamente corretta:
-- 10 ordini più recenti
SELECT TOP (10) OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC;
Diciamo che i tuoi dati hanno 20 ordini, cinque dei quali sono stati piazzati nello stesso identico secondo (import CSV a mezzanotte, ad esempio). Quelle cinque righe hanno OrderDate identico. Sono in pareggio.
Quando c’è un pareggio, SQL Server sceglie un ordine non specificato — essenzialmente l’ordine in cui gli capita di produrlo. Niente garantisce che rieseguire la query ti dia lo stesso ordine. Niente garantisce che corrisponda all’ordine che vede il tuo collega sulla sua macchina. Niente garantisce che corrisponda a ciò che è uscito dal job notturno di ieri.
Per la paginazione, è catastrofico. “Pagina 1 mostra gli ordini A B C D E. Click avanti. Pagina 2 mostra gli ordini D E F G H.” D ed E sono apparsi due volte perché l’ordinamento non era deterministico tra le chiamate.
La soluzione: aggiungi sempre un tiebreaker — idealmente la chiave primaria.
SELECT TOP (10) OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC,
OrderId DESC; -- ← pareggi rotti in modo deterministico
OrderId è la chiave primaria clustered, quindi ogni riga ha un valore unico. L’ordinamento è ora totalmente ordinato: non ci sono due righe che si confrontano come uguali, quindi l’output è riproducibile tra le esecuzioni.
Rendi “includi la chiave primaria in ORDER BY” un riflesso. Il giorno in cui spedisci un endpoint di paginazione senza, è il giorno in cui il supporto riceve un bug report sulle righe duplicate.
TOP con un valore o una variabile
TOP limita le righe restituite:
SELECT TOP (10) ... -- avvolgi sempre tra parentesi per chiarezza
SELECT TOP 10 ... -- vecchia sintassi, funziona anche
SELECT TOP (@n) ... -- parametrizzato; richiede parentesi
SELECT TOP (10) WITH TIES ... -- include i pareggi sull'ultima riga
WITH TIES è una gemma nascosta. Considera questo: “mostrami i 3 ordini più grandi in Italia,” ma se il 3° e il 4° ordine hanno lo stesso Total, includili entrambi.
SELECT TOP (3) WITH TIES
OrderId, Total, CountryCode
FROM Sales.Orders
WHERE CountryCode = 'IT'
ORDER BY Total DESC;
Se tre righe hanno i top-3 totali e una quarta riga pareggia con la 3°, ottieni 4 righe in output. Molto utile per query in stile classifica dove non vuoi tagliare arbitrariamente elementi a pari merito.
TOP senza ORDER BY è un lancio di moneta
SELECT TOP (10) OrderId FROM Sales.Orders;
Ritorna qualche 10 righe. Potrebbero essere le prime 10 nell’ordine di inserimento. Potrebbero essere dieci a caso da pagine diverse. Non garantito. Non farlo a meno che tu non stia genuinamente campionando per esplorazione.
OFFSET ... FETCH: la paginazione moderna
La sintassi di paginazione SQL-standard, in SQL Server dal 2012:
-- Pagina 3, 10 righe per pagina
SELECT OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;
OFFSET N ROWS — salta le prime N righe. FETCH NEXT M ROWS ONLY — restituisci le M successive. Richiede ORDER BY. Diretto, ed è la scelta giusta per UI admin, viste a lista, e qualsiasi dashboard paginata.
Pattern di chiamata da un’applicazione:
-- Parametrizzato, page-size fissa a 25
DECLARE @page INT = 3;
DECLARE @pageSize INT = 25;
SELECT OrderId, OrderDate
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET (@page - 1) * @pageSize ROWS
FETCH NEXT @pageSize ROWS ONLY;
Nota sulle performance: OFFSET non è gratis. Il motore deve comunque attraversare le prime N righe per buttarle via, poi ritornare le M successive. Per la pagina 1000 di una tabella da un milione di righe, il motore legge 10.000 righe di pagine d’indice, il che è lento.
Per paginazione molto profonda su tabelle calde, la paginazione keyset (“mostrami i prossimi 25 ordini dopo quello con OrderId 12345”) è più veloce di OFFSET:
-- Paginazione keyset: ricorda l'ultima riga della pagina precedente
DECLARE @lastOrderDate DATETIME2(0) = '2026-03-15 10:22:00';
DECLARE @lastOrderId BIGINT = 4821;
SELECT TOP (25) OrderId, OrderDate
FROM Sales.Orders
WHERE (OrderDate < @lastOrderDate)
OR (OrderDate = @lastOrderDate AND OrderId < @lastOrderId)
ORDER BY OrderDate DESC, OrderId DESC;
Questo usa l’indice direttamente — nessun overhead di “salta N e inizia a contare”. Passa OrderDate e OrderId dell’ultima riga alla chiamata successiva. Funziona splendidamente per UI con scroll infinito e API ben paginate. Per la dashboard admin dove qualcuno clicca “pagina 7,” OFFSET va comunque bene.
L’errore del “top N per gruppo”
Richiesta reale molto comune in Runehold: “dammi i 3 ordini più grandi per paese.”
La query intuitiva-ma-sbagliata:
-- QUESTO NON FUNZIONA COME PENSI
SELECT TOP (3) OrderId, CountryCode, Total
FROM Sales.Orders
ORDER BY CountryCode, Total DESC;
Questo ritorna 3 righe in totale, non 3 per paese. TOP limita l’intero result set, non i gruppi.
La risposta giusta usa una funzione finestra — ROW_NUMBER() partizionata per gruppo. I dettagli completi sono nella lezione 12, ma ecco il pattern:
SELECT OrderId, CountryCode, Total
FROM (
SELECT OrderId,
CountryCode,
Total,
ROW_NUMBER() OVER (
PARTITION BY CountryCode
ORDER BY Total DESC, OrderId DESC
) AS rn
FROM Sales.Orders
) AS ranked
WHERE rn <= 3
ORDER BY CountryCode, Total DESC;
ROW_NUMBER() OVER (PARTITION BY CountryCode ORDER BY Total DESC) assegna a ogni riga un rango all’interno del suo paese. La query esterna mantiene i ranghi 1, 2, 3. Esattamente 3 righe per paese.
Questo è uno dei pattern più utili nell’SQL pratico. Memorizza la forma. Sviscereremo le finestre per bene nella lezione 12.
Collation e ordine di ordinamento per il testo
L’ordine di ordinamento del testo dipende dalla collation della colonna o del database (lezione 4). Una collation case-insensitive accent-sensitive (il default di SQL Server in Europa occidentale) tratta 'anne' e 'ANNE' come uguali ai fini dell’ordinamento ma 'café' e 'cafe' come differenti.
Il customer support di Runehold lancia report come “top clienti ordinati alfabeticamente per nome.” In un database multi-locale, quest’ordine di ordinamento è una decisione di business:
- Case-insensitive:
'Anne'e'anne'si ordinano insieme. - Accent-sensitive:
'Bérénice'viene dopo'Bernice'. - Specifico per locale: il tedesco
'ß'si ordina con'ss'in una collation, dopo's'in un’altra.
Puoi forzare una collation specifica su una singola query:
SELECT Name
FROM Sales.Customer
ORDER BY Name COLLATE Latin1_General_100_CI_AI; -- accent-insensitive anche
Se l’ordine di ordinamento internazionale conta per il tuo team (spesso lo fa in un’azienda UE come Runehold), accordatevi su una collation, documentatela, attenetevici.
Esegui questo sulla tua macchina
USE Runehold;
GO
-- Aggiungi qualche ordine in più così l'ordinamento ha pareggi interessanti
INSERT INTO Sales.Orders (CustomerId, OrderDate, Total, CountryCode, VatRate)
VALUES (1, '2026-04-01 10:00:00', 19.00, 'NL', 0.2100),
(2, '2026-04-01 10:00:00', 19.00, 'IT', 0.2200), -- stessa data, stesso totale
(3, '2026-04-01 10:00:00', 129.00, 'DE', 0.1900),
(4, '2026-04-05 14:30:00', 75.00, 'RO', 0.1900);
-- Query 1: TOP non deterministico
-- Eseguila 5 volte; le 10 righe possono tornare in ordini diversi.
SELECT TOP (5) OrderId, OrderDate, Total
FROM Sales.Orders
ORDER BY OrderDate DESC;
-- Query 2: la stessa, con un tiebreaker — sempre riproducibile
SELECT TOP (5) OrderId, OrderDate, Total
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC;
-- Query 3: paginazione basata su OFFSET
DECLARE @page INT = 1;
DECLARE @pageSize INT = 3;
SELECT OrderId, OrderDate, Total
FROM Sales.Orders
ORDER BY OrderDate DESC, OrderId DESC
OFFSET (@page - 1) * @pageSize ROWS
FETCH NEXT @pageSize ROWS ONLY;
-- Query 4: TOP WITH TIES
SELECT TOP (2) WITH TIES
OrderId, CountryCode, Total
FROM Sales.Orders
ORDER BY Total DESC;
-- Se 2° e 3° hanno entrambi lo stesso Total, ottieni 3 righe.
-- Query 5: top-2 ordini per paese (anteprima della lezione 12)
SELECT OrderId, CountryCode, Total
FROM (
SELECT OrderId, CountryCode, Total,
ROW_NUMBER() OVER (
PARTITION BY CountryCode
ORDER BY Total DESC, OrderId DESC
) AS rn
FROM Sales.Orders
) AS ranked
WHERE rn <= 2
ORDER BY CountryCode, Total DESC;
Esegui ogni query. Predici il numero di righe prima di guardare. Nota come la versione con tiebreaker nella query 2 sia riproducibile mentre la query 1 potrebbe non esserlo.
Il modulo 1 del corso è finito. Ora sai creare tabelle, scegliere tipi di dato, scrivere SELECT, filtrare con WHERE, gestire i NULL, e ordinare/paginare correttamente. È più che sufficiente per essere produttivo nella maggior parte dei lavori di data engineering.
Il modulo 2 inizia con la prossima lezione: i join. Probabilmente l’argomento SQL singolarmente più importante. Copriremo INNER, LEFT, RIGHT, FULL, CROSS, e la spiegazione col diagramma di Venn che è tecnicamente sbagliata ma utile lo stesso.