SQL Server, dalle fondamenta Lezione 7 / 40

WHERE, NULL, e perché i tuoi filtri mentono

Logica a tre valori, IS NULL contro = NULL, ISNULL contro COALESCE, e il bug silenzioso che ogni sviluppatore scrive una volta. Con esempi dal customer-support di Runehold.

Ecco l’unico fatto SQL che ogni sviluppatore impara col dolore e poi è infastidito di averlo dovuto imparare col dolore: SQL non è un sistema di logica a due valori. Ha tre possibili risultati da un confronto: TRUE, FALSE, e UNKNOWN. Il terzo è NULL, e se non capisci come funziona, le tue query elimineranno silenziosamente righe che volevi o terranno silenziosamente righe che non volevi. Nessun errore. Solo risposte sbagliate.

Oggi sistemiamo la cosa una volta per tutte.

Cosa significa davvero NULL

In SQL, NULL non è zero. Non è una stringa vuota. Non è “falso.” Significa “valore sconosciuto, o valore assente, il database non ha informazioni qui.”

Ogni tipo di dato in SQL Server può essere NULL a meno che tu non abbia dichiarato la colonna NOT NULL. La colonna Email di una riga potrebbe avere una mail vera, oppure essere NULL se il cliente non te l’ha mai data. Una OrderDate potrebbe essere NULL perché il carrello non è ancora stato finalizzato. Uno ShippedAt è NULL finché il magazzino non scansiona il pacco.

Questo è utile. I problemi cominciano quando confronti.

I confronti con NULL ritornano NULL, non FALSE

Prova prima nella tua testa, poi eseguilo:

SELECT
    1 = 1                           AS one_eq_one,          -- TRUE
    1 = 2                           AS one_eq_two,          -- FALSE
    1 = NULL                        AS one_eq_null,         -- NULL  (non FALSE!)
    NULL = NULL                     AS null_eq_null,        -- NULL  (non TRUE!)
    NULL <> NULL                    AS null_neq_null,       -- NULL
    1 <> NULL                       AS one_neq_null,        -- NULL
    NULL + 1                        AS null_plus_one;       -- NULL

La regola: qualsiasi espressione che coinvolge NULL valuta a NULL. Non “falso.” Il modo del database di dire “non posso dirtelo; un lato è sconosciuto.”

Le clausole WHERE tengono solo le righe dove il filtro è TRUE. Le righe dove il filtro è FALSE sono escluse. Le righe dove il filtro è NULL sono pure escluse. Quindi “non FALSE” e “non TRUE” sono in realtà la stessa cosa dal punto di vista del filtro, ma concettualmente conta quando ragioni sulla tua query.

IS NULL, non = NULL

Poiché = NULL è NULL, non TRUE o FALSE, non puoi mai usare = per controllare NULL. Devi usare IS NULL e IS NOT NULL:

-- SBAGLIATO: ritorna zero righe anche se ci sono clienti con Email NULL
SELECT CustomerId FROM Sales.Customer WHERE Email = NULL;

-- GIUSTO
SELECT CustomerId FROM Sales.Customer WHERE Email IS NULL;
SELECT CustomerId FROM Sales.Customer WHERE Email IS NOT NULL;

Se avessi 1 € per ogni bug che ho visto in cui qualcuno ha scritto WHERE deleted_at = NULL chiedendosi perché il filtro di soft-delete non funzionava, avrei abbastanza per coprire un abbonamento Runehold Loremaster.

La trappola del “non uguale”, con i dati

Ecco una trappola che morde tutti, me compreso la settimana scorsa.

Il team di customer support di Runehold vuole mandare un sondaggio a ogni cliente fuori dai Paesi Bassi. La query ovvia:

SELECT Email
FROM Sales.Customer
WHERE Country <> 'NL';

Sembra giusta. È sbagliata.

A causa della logica a tre valori, qualsiasi riga in cui Country è NULL non corrisponde a <> 'NL' — perché NULL <> 'NL' è NULL, non TRUE. I clienti senza paese vengono silenziosamente esclusi dal sondaggio. Te li sei persi. Marketing manda la campagna, e tre settimane dopo qualcuno nota che il segmento “Altri paesi” aveva molte meno persone del previsto.

La forma sicura:

SELECT Email
FROM Sales.Customer
WHERE Country <> 'NL' OR Country IS NULL;

-- o, spesso più chiaro:
SELECT Email
FROM Sales.Customer
WHERE ISNULL(Country, '') <> 'NL';

ISNULL(col, default) sostituisce NULL con il default prima del confronto. Ora un Country NULL è trattato come '' e '' <> 'NL' è TRUE. Il sondaggio va a tutti tranne i clienti NL.

Ogni volta che scrivi <>, fermati e chiedi: “questa colonna può essere NULL, e voglio quelle righe o no?” Ci vogliono due secondi. Salva campagne.

ISNULL contro COALESCE

Due funzioni che fanno quasi la stessa cosa. Entrambe ritornano il primo valore non NULL dai loro argomenti. Differenze che contano:

-- ISNULL: esattamente due argomenti, specifico di T-SQL
SELECT ISNULL(Phone, 'no phone') FROM Sales.Customer;

-- COALESCE: due o più argomenti, SQL standard
SELECT COALESCE(Phone, MobilePhone, 'no phone') FROM Sales.Customer;

COALESCE è il default giusto. È la grafia SQL-standard, funziona tra database se mai porterai le tue query, e accetta qualsiasi numero di argomenti. ISNULL è più corto per il caso a due argomenti ed è leggermente più veloce in alcuni micro-benchmark, ma nessuno se ne accorge fuori dai sogni dell’ottimizzatore di query.

Trabocchetto sottile che mi ha causato dolore:

-- ISNULL ritorna il tipo di dato del primo argomento
SELECT ISNULL(NULL, 'fallback value that is too long');
-- Ritorna 'fallback v' — troncato a 1 carattere perché
-- ISNULL ha pensato che il tipo fosse implicito dal letterale NULL.
-- Nelle versioni moderne di SQL Server avvisa; nelle vecchie troncava in silenzio.

-- COALESCE usa le regole dello standard SQL: sceglie il tipo con
-- la precedenza più alta tra tutti gli argomenti. Più sicuro.
SELECT COALESCE(NULL, 'fallback value that is too long');
-- Ritorna la stringa intera.

La precedenza dei tipi è una tana del coniglio. Nel dubbio, fai cast esplicito.

NULL e aritmetica

L’aritmetica con NULL propaga NULL:

SELECT 5 + NULL;               -- NULL
SELECT 10 / NULL;              -- NULL
SELECT 'foo' + NULL;           -- NULL  (concatenazione di stringhe)

Quindi se hai una colonna Discount che può essere NULL, non scrivere:

SELECT Total - Discount AS NetTotal FROM Sales.Orders;

…perché per ogni riga in cui Discount è NULL, anche NetTotal sarà NULL. Probabilmente non quello che volevi. Usa COALESCE:

SELECT Total - COALESCE(Discount, 0) AS NetTotal FROM Sales.Orders;

Questo pattern — “se il valore è NULL, trattalo come zero per i calcoli” — torna fuori in continuazione.

NULL e aggregati

Le funzioni di aggregazione (SUM, AVG, COUNT, MAX, MIN) generalmente ignorano i NULL. Si comportano come “dammi l’aggregato dei valori non NULL.”

-- La tabella ha 5 righe: valori Total 10, 20, NULL, 30, 40
SELECT SUM(Total) AS total_sum,        -- 100 (10+20+30+40)
       AVG(Total) AS total_avg,        -- 25  (100 / 4, non 100 / 5)
       COUNT(Total) AS count_non_null,  -- 4
       COUNT(*)     AS count_rows       -- 5
FROM Sales.Orders;

COUNT(*) conta le righe a prescindere da NULL. COUNT(column) conta solo le righe in cui column non è NULL. È una trappola comune ai colloqui.

La clausola WHERE e l’amicizia con gli indici

WHERE guida quale indice SQL Server sceglie (se ne sceglie uno). Scrivere un WHERE che è SARGable — abbreviazione di “Search ARGument-able”, che significa che SQL Server può fare un seek sull’indice — è metà della battaglia per scrivere query veloci.

SARGable (l’indice può fare seek):

WHERE CountryCode = 'IT'
WHERE OrderDate >= '2026-01-01' AND OrderDate < '2026-04-01'
WHERE Total BETWEEN 50 AND 500
WHERE CustomerId IN (1, 2, 3, 4)
WHERE Email LIKE 'anne%'     -- ricerca per prefisso: OK

Non SARGable (l’indice deve fare scan):

WHERE YEAR(OrderDate) = 2026           -- funzione sulla colonna
WHERE UPPER(Name) = 'ANNE DE VRIES'    -- funzione sulla colonna
WHERE Email LIKE '%@gmail.com'          -- wildcard iniziale
WHERE CAST(OrderId AS NVARCHAR) = '42'  -- conversione esplicita
WHERE Total + Discount > 100            -- aritmetica sulla colonna
WHERE OrderDate = @date  -- (se @date è di tipo diverso, conversione implicita)

La regola: non mettere la colonna indicizzata dentro una funzione, un cast o un’espressione. Sposta la trasformazione dall’altra parte del confronto quando possibile:

-- Cattivo: funzione sulla colonna
WHERE CAST(OrderDate AS DATE) = '2026-03-15'

-- Buono: riscrivi come range
WHERE OrderDate >= '2026-03-15' AND OrderDate < '2026-03-16'

-- Cattivo: aritmetica sulla colonna
WHERE Total + 10 > @threshold

-- Buono: sposta l'aritmetica dall'altra parte
WHERE Total > @threshold - 10

Quest’unica abitudine vale più di qualsiasi consiglio sull’indicizzazione. Un indice altrimenti perfetto è inutile contro un WHERE non-SARGable.

IN, NOT IN, e la trappola NULL colpisce ancora

IN controlla l’appartenenza a una lista:

SELECT Name FROM Sales.Customer WHERE Country IN ('IT', 'ES', 'FR');

Diretto. NOT IN è dove diventa strano:

-- Sembra "clienti non in NL, DE, o IT"
SELECT Name FROM Sales.Customer
WHERE Country NOT IN ('NL', 'DE', 'IT');

Va bene a meno che la lista ('NL', 'DE', 'IT') contenga un NULL, o provenga da una subquery che può ritornare NULL. Allora l’intero NOT IN valuta a NULL per ogni riga, e ottieni zero risultati. La causa è la logica a tre valori applicata alla negazione.

Pattern moderno più sicuro quando è coinvolta una subquery:

-- Diciamo che cerchiamo i clienti che non hanno fatto un ordine
-- Forma NOT IN, pericolosa se CustomerId può mai essere NULL in Orders
SELECT Name FROM Sales.Customer
WHERE CustomerId NOT IN (SELECT CustomerId FROM Sales.Orders);

-- Forma NOT EXISTS, sicura contro NULL
SELECT Name FROM Sales.Customer c
WHERE NOT EXISTS (
    SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);

Usa NOT EXISTS invece di NOT IN con le subquery. La logica è equivalente, è sicuro contro NULL, e di solito produce un piano di esecuzione identico o migliore.

Esegui questo sulla tua macchina

USE Runehold;
GO

-- Facciamo in modo che una riga di Customer abbia Country NULL per dimostrare
INSERT INTO Sales.Customer (Name, Country)
VALUES ('Mysterious Stranger', NULL);

-- Query 1: la trappola del "<>"
-- Quanti clienti NON sono nei Paesi Bassi?
SELECT COUNT(*) AS wrong_count
FROM Sales.Customer
WHERE Country <> 'NL';
-- Ritorna 3 — manca il misterioso sconosciuto.

SELECT COUNT(*) AS right_count
FROM Sales.Customer
WHERE Country <> 'NL' OR Country IS NULL;
-- Ritorna 4.

-- Query 2: fallback sicuro
SELECT Name,
       ISNULL(Country, '??') AS CountryOrMystery
FROM Sales.Customer
ORDER BY Country;

-- Query 3: propagazione di NULL nell'aritmetica
-- Aggiungi una colonna nullable Discount a Orders per la demo
ALTER TABLE Sales.Orders ADD Discount DECIMAL(19, 4) NULL;

-- Alcuni ordini hanno sconto, altri no
UPDATE Sales.Orders SET Discount = 5.00 WHERE OrderId IN (1, 3);

SELECT OrderId,
       Total,
       Discount,
       Total - Discount               AS NetWrong,   -- NULL per le righe senza sconto
       Total - COALESCE(Discount, 0)  AS NetRight
FROM Sales.Orders
ORDER BY OrderId;

-- Query 4: l'aggregato ignora NULL
SELECT
    COUNT(*)         AS rows_total,     -- tutte le righe
    COUNT(Discount)  AS rows_with_disc, -- solo non-NULL
    SUM(Discount)    AS total_discount, -- righe NULL saltate
    AVG(Discount)    AS avg_discount    -- media solo sui non-NULL
FROM Sales.Orders;

-- Pulizia
ALTER TABLE Sales.Orders DROP COLUMN Discount;
DELETE FROM Sales.Customer WHERE Country IS NULL;

Eseguilo riga per riga. Leggi ogni risultato. Predici ogni numero prima di guardarlo. Quando sei sorpreso, rileggi la sezione pertinente sopra.

Prossima lezione: ORDER BY, TOP, e la paginazione con OFFSET ... FETCH. Più l’unico errore che ogni sviluppatore fa quando prova a “ottenere le top N righe per gruppo” la prima volta.

Cerca