SQL Server, dalle fondamenta Lezione 4 / 40

Tipi di dato: la differenza tra VARCHAR(50) e una causa legale

VARCHAR vs NVARCHAR, DATETIME vs DATETIME2, DECIMAL vs FLOAT, e le conseguenze sorprendentemente costose dell'essere superficiali sui tipi di dato in SQL Server.

La prima volta che ho visto una tabella di produzione dove ogni singola colonna era NVARCHAR(MAX), ho pensato che dovesse essere un database di test che qualcuno si era scordato di pulire. Non lo era. Era il customer master. Anni prima qualcuno aveva deciso che “MAX vuol dire più grande, e vogliamo stare al sicuro”, e la tabella era cresciuta a quattro terabyte mentre restituiva un cliente alla volta a ogni query.

I tipi di dato non sono noiosi. Sono la singola cosa più grande che puoi fare bene il primo giorno e che salverà il te del futuro dal piangere alle 3 del mattino. Decidono quanto spazio su disco prendono i tuoi dati, quanto velocemente girano le query, come SQL Server stima la cardinalità, se i tuoi indici funzionano, se puoi ordinare correttamente, se i tuoi backup ci stanno sul nastro notturno. Decidono se un piano di esecuzione sceglie un index seek o un table scan. Decidono se un JOIN usa hash o merge. Decidono se il tuo capo ti licenzia.

Questa lezione copre i tipi che userai il 95% delle volte, cosa scegliere, cosa evitare, e perché. Stampala. Attaccala al monitor. Rileggila tra una settimana.

Le tre categorie

SQL Server ha circa 30 tipi di dato. Ricadono in tre categorie:

  1. Numerici — interi, decimali, valuta.
  2. Carattere / stringa — stringhe a byte singolo, stringhe Unicode, stringhe corte a lunghezza fissa, blob giganti.
  3. Data e ora — date, ore, datetime, datetime con timezone.

Più una manciata di tipi speciali (BIT, UNIQUEIDENTIFIER, VARBINARY, XML, JSON dal 2025+, tipi spaziali, sql_variant) che userai meno spesso.

Andiamo per categoria con opinioni.

Tipi numerici

Interi: TINYINT, SMALLINT, INT, BIGINT

Quattro tipi interi, in ordine di dimensione:

TipoByteRange
TINYINT1da 0 a 255 (niente negativi!)
SMALLINT2da -32.768 a +32.767
INT4circa da -2,1 miliardi a +2,1 miliardi
BIGINT8circa da -9,2 quintilioni a +9,2 quintilioni

Il modello mentale: usa il più piccolo che non finirà mai.

TINYINT è ottimo per cose come codici di stato, numeri di mese, o piccole enumerazioni. Se i valori stanno tra 0 e 255, usalo.

SMALLINT per anni, conteggi piccoli, cose che non supereranno i 30k.

INT è il cavallo da tiro. Primary key auto-incrementanti, contatori, quantità, quasi tutto. Il range di due miliardi di righe è più che abbastanza per il 95% dei casi.

BIGINT per quando supererai i due miliardi di righe o ti servono conteggi grandi. Tabelle di eventi, tabelle di log, sistemi OLTP ad alto volume. Ecco la regola: ogni tabella in cui ti aspetti più di 500 milioni di righe nel suo arco di vita dovrebbe avere una primary key BIGINT dal primo giorno. Convertire una PK INT in BIGINT più tardi è possibile ma doloroso, e se sei referenziato da 20 foreign key le cambierai tutte.

Decimali: DECIMAL(p, s) / NUMERIC(p, s)

Stessa cosa, nome diverso. Usa DECIMAL. p è la precisione (cifre totali), s è la scala (cifre dopo la virgola). DECIMAL(18, 2) può memorizzare numeri come 1234567890123456.78.

Usa DECIMAL per tutti i soldi, tutte le percentuali, e qualsiasi cosa tu debba sommare o confrontare con esattezza. È preciso. Niente errori di arrotondamento. Più lento degli interi ma vale la pena.

Float: FLOAT e REAL

Floating-point approssimato. FLOAT(53) è doppia precisione a 8 byte. REAL è singola precisione a 4 byte. Veloce, ma gli errori di arrotondamento sono reali. SELECT 0.1 + 0.2 restituisce 0.3; SELECT CAST(0.1 AS FLOAT) + CAST(0.2 AS FLOAT) restituisce 0.30000000000000004.

Usa FLOAT solo per valori scientifici dove la precisione non deve essere esatta: letture di sensori, misure statistiche, latitudini stimate. Non usare mai FLOAT per i soldi. Ho visto un job di riconciliazione finanziaria produrre uno sfasamento di 0,0000001€ ogni mese esattamente per questo. Il rilievo dell’audit ci ha messo tre mesi a chiudersi.

Money: MONEY e SMALLMONEY

Non farlo. MONEY sembra progettato per la valuta ma ha comportamenti di precisione strani, regole di conversione di tipo cattive, e il risparmio in storage rispetto a DECIMAL(19, 4) è trascurabile. Il consenso del settore da quindici anni è usa DECIMAL(19, 4) per le valute, non MONEY.

Booleani: BIT

SQL Server non ha un vero tipo BOOLEAN. Ha BIT, che memorizza 0, 1, o NULL. Fino a 8 colonne BIT su una riga condividono un singolo byte, quindi sono efficienti.

Attenzione: le colonne BIT non si usano in tutti gli stessi posti degli interi. Non puoi WHERE IsActive = TRUE — non c’è una keyword TRUE in SQL. Usa WHERE IsActive = 1.

Tipi stringa

Qui è dove la maggior parte della gente fa più errori. Leggi attentamente.

Fisso vs variabile: CHAR vs VARCHAR, NCHAR vs NVARCHAR

  • CHAR(n) / NCHAR(n) — lunghezza fissa. Sempre n caratteri, riempiti con spazi. Spreca spazio se la maggior parte dei valori è più corta.
  • VARCHAR(n) / NVARCHAR(n) — lunghezza variabile. Memorizza esattamente la stringa che gli dai, più 2 byte di metadati di lunghezza.

Usa quasi sempre VARCHAR o NVARCHAR. CHAR a lunghezza fissa è appropriato solo quando la colonna è davvero a lunghezza fissa (codice paese CHAR(2), codice valuta CHAR(3)), perché il padding fa male e la lunghezza variabile è più flessibile.

Singolo byte vs Unicode: VARCHAR vs NVARCHAR

  • VARCHAR — 1 byte per carattere (per ASCII). Usa la collation che la colonna ha, che ne determina il character set e l’ordine di sort. Può memorizzare caratteri accentati nelle collation europee occidentali a 1 byte per carattere usando il range esteso 0-255. Non può memorizzare cinese, arabo, emoji, o la maggior parte degli script non latini.
  • NVARCHAR — 2 byte per carattere (UTF-16). Può memorizzare qualsiasi carattere Unicode. Doppio storage per dati solo-ASCII, ma niente drammi di collation.

Nel 2026, la mia regola è: default a NVARCHAR a meno che tu non sappia di non avere bisogno di Unicode. Sì, raddoppia lo storage per dati ASCII. Il disco è economico; le segnalazioni di bug da clienti giapponesi i cui nomi il tuo database corrompe non lo sono. L’unica eccezione: codici interni che controlli tu (codici di stato, codici paese ISO, SKU) dove garantisci solo ASCII. Quelli vanno bene come VARCHAR.

SQL Server 2019 ha aggiunto una collation UTF-8 che permette alle colonne VARCHAR di memorizzare UTF-8. Se sei su 2019+ e ti va di fissare una collation specifica, VARCHAR con una collation UTF-8 ti dà il meglio di entrambi i mondi: codifica a larghezza variabile che è ancora a singolo byte per ASCII. È una mossa da pro; se ti confonde, usa solo NVARCHAR e vai avanti.

Quanto deve essere grande n?

Un errore molto comune: VARCHAR(50) per i nomi, VARCHAR(255) per le email, VARCHAR(max) “tanto per stare al sicuro”.

Regola: dimensiona per l’uso reale, non per casi limite estremi.

  • Nomi: NVARCHAR(100) è più che sufficiente. Quasi nessun nome reale è più lungo di 100 caratteri.
  • Email: NVARCHAR(254) — è il limite RFC.
  • Numeri di telefono: NVARCHAR(30) — lascia spazio per prefissi internazionali e formattazione.
  • URL: NVARCHAR(2048) — i browser di solito si fermano intorno ai 2000.
  • Note libere: NVARCHAR(4000) o NVARCHAR(MAX) se ti serve davvero qualcosa di grande.

Perché non usare semplicemente NVARCHAR(MAX) ovunque? Perché MAX è diverso sotto il cofano. I valori fino a 8000 byte sono memorizzati in-row. I valori più grandi vengono memorizzati in pagine LOB separate, con un puntatore nella riga. Il motore tratta le colonne MAX con più cautela: non possono far parte di una chiave di indice, hanno restrizioni in certe funzionalità T-SQL, e l’ordinamento al volo e i memory grant diventano strani. Default a tipi dimensionati. Usa MAX quando ti serve davvero senza limiti.

TEXT, NTEXT, IMAGE

No. Sono deprecati, lo sono dal SQL Server 2005, e prima o poi verranno rimossi. Se li vedi in un vecchio database, pianifica una migrazione a VARCHAR(MAX), NVARCHAR(MAX), o VARBINARY(MAX). Li trovo ancora occasionalmente in giro, sempre in codice che risale all’amministrazione Bush.

Tipi data e ora

DATETIME — il vecchio

Tipo più vecchio. 8 byte. Precisione di 3,33 millisecondi (arrotonda a .000, .003, .007). Range dal 1753 al 9999. È quello che usano i database più vecchi.

Non usarlo per codice nuovo. La precisione è strana, il range è enorme, ed è sostituito da tipi migliori.

DATETIME2(n) — il moderno

Da 6 a 8 byte. Precisione fino a 100 nanosecondi. Range dallo 0001 al 9999.

DATETIME2(0) — niente frazioni di secondo. Precisione al secondo. 6 byte. Ottimo per la maggior parte dei timestamp. DATETIME2(3) — precisione al millisecondo. 7 byte. Usalo se devi matchare eventi sub-secondo. DATETIME2(7) — precisione completa a 100ns. 8 byte. Usalo per log e dati di evento.

Default a DATETIME2(0) o DATETIME2(3). Più preciso di DATETIME, più piccolo, e il range è sensato.

DATE e TIME

Altri due per quando vuoi solo la data o solo l’ora.

  • DATE — 3 byte. Range dallo 0001 al 9999. Usalo per compleanni, date di evento, qualsiasi cosa in cui l’ora del giorno è irrilevante.
  • TIME(n) — da 3 a 5 byte. Usalo raramente; la maggior parte delle volte vuoi un datetime completo.

DATETIMEOFFSET(n) — con timezone

10 byte. Memorizza il datetime più un offset da UTC.

È la scelta giusta se devi preservare l’informazione di fuso orario dell’evento originale. Memorizzare “l’utente ha cliccato alle 14:30 ora locale a Milano, che sono le 13:30 UTC” è legittimamente diverso dal memorizzare “13:30 UTC senza idea di cosa stessero pensando”. Avremo un’intera lezione sulla palude dei fusi orari (lezione 15). Per ora: se ti serve davvero la consapevolezza del fuso orario, usa DATETIMEOFFSET. Se non ti serve, usa DATETIME2 e memorizza tutto in UTC.

SMALLDATETIME — no

4 byte, precisione al minuto, range dal 1900 al 2079. Oscuro, salva qualche byte, ti vede nel 2079. Salta.

I tipi speciali che vale la pena conoscere

UNIQUEIDENTIFIER

16 byte. Memorizza un GUID. Genera con NEWID() (casuale) o NEWSEQUENTIALID() (monotono).

Utile per sistemi distribuiti, primary key che devono essere generate fuori dal database, e integrazione con sistemi che usano GUID. Pessima scelta per un indice clustered (vedi lezione 21). Usalo quando hai davvero bisogno di un ID globalmente unico; non usarlo perché sembra moderno.

VARBINARY(n) / VARBINARY(MAX)

Per dati binari — file, immagini, blob cifrati. MAX per qualsiasi cosa grande. Nella maggior parte delle app dovresti probabilmente memorizzare i file grandi nel blob storage (S3, Azure Blob) e l’URL in SQL Server, ma VARBINARY(MAX) esiste per quando hai davvero bisogno di binario nel database.

BIT

Booleani. Coperto sopra.

XML e JSON

XML è un tipo XML pieno di funzionalità con supporto al querying. Raramente la scelta giusta nel 2026 a meno che tu non sia già pieno di XML.

JSON è stato, per molti anni, “memorizzalo solo in NVARCHAR(MAX) e SQL Server ha funzioni per interrogarlo”. SQL Server 2025 introduce un vero tipo JSON con storage binario. Se sei su una versione più vecchia, NVARCHAR(MAX) + JSON_VALUE() / JSON_QUERY() / OPENJSON() è l’idioma.

Conversioni implicite: il killer silenzioso

Ogni scelta di tipo di dato ha un effetto a catena: i confronti e i join vanno meglio quando i tipi corrispondono.

Se hai CustomerId INT e scrivi:

SELECT * FROM Customer WHERE CustomerId = '42';

SQL Server fa una conversione implicita sul lato della colonna. La stringa '42' viene convertita prima a INT (in realtà il motore converte ogni CustomerId a VARCHAR e poi confronta, secondo le regole built-in di precedenza dei tipi). Risultato: il tuo indice su CustomerId non può più essere fatto in seek. Hai appena degradato un seek in uno scan, e non vedrai nessun errore, solo una query più lenta.

Ecco la regola che Microsoft chiama “SARGability” (Search ARGument-able): un predicato è SARGable quando SQL Server può usarlo per fare seek su un indice. Funzioni, conversioni implicite, e aritmetica sulla colonna indicizzata rompono tutte la SARGability. Buono:

WHERE CreatedAt >= '2025-01-01' AND CreatedAt < '2026-01-01'

Cattivo:

WHERE YEAR(CreatedAt) = 2025

Cattivo:

WHERE CAST(CreatedAt AS DATE) = '2025-12-25'

Buono:

WHERE CreatedAt >= '2025-12-25' AND CreatedAt < '2025-12-26'

Fai matchare i tipi. Non mettere funzioni sulla colonna indicizzata. I tuoi indici ti ringrazieranno.

Collation: il colpo di scena

Ogni colonna stringa ha una collation — un set di regole che determinano come i caratteri vengono confrontati e ordinati. SQL_Latin1_General_CP1_CI_AS è un default comune (case-insensitive, accent-sensitive europeo occidentale). Latin1_General_100_CS_AS_SC_UTF8 è una collation Unicode case-sensitive UTF-8 introdotta in SQL Server 2019.

Le collation influenzano:

  • Se 'foo' = 'FOO' è vero (case sensitivity)
  • Se 'cafe' = 'café' è vero (accent sensitivity)
  • L’ordinamento (alfabetico? specifico per cultura?)

Mischiare collation nei join causa conflitti di collation che producono il divertente errore: Cannot resolve the collation conflict between "X" and "Y" in the equal to operation. Lo risolvi facendo cast esplicito di un lato: col1 COLLATE Latin1_General_CI_AS = col2.

Lezione: scegli una collation per il tuo database, attieniti a quella su tutte le colonne stringa, e non importare dati con una collation diversa a meno che tu non sia pronto al divertimento.

Esegui questo sulla tua macchina

Una piccola demo del perché i tipi di dato contano. Copia-incolla, esegui, leggi i tempi.

USE tempdb;
GO

-- Due tabelle, una ben tipizzata, una sciatta
CREATE TABLE dbo.WellTyped (
    CustomerId INT           NOT NULL,
    Name       NVARCHAR(100) NOT NULL,
    CreatedAt  DATETIME2(0)  NOT NULL
);

CREATE TABLE dbo.Sloppy (
    CustomerId NVARCHAR(50)  NOT NULL,  -- numerico memorizzato come stringa
    Name       NVARCHAR(MAX) NOT NULL,  -- senza limite per nessun motivo
    CreatedAt  DATETIME       NOT NULL  -- tipo vecchio
);

-- Inserisce 1M di righe in ognuna
WITH Nums AS (
    SELECT TOP (1000000) ROW_NUMBER() OVER (ORDER BY a.object_id) AS n
    FROM sys.all_objects a CROSS JOIN sys.all_objects b
)
INSERT INTO dbo.WellTyped (CustomerId, Name, CreatedAt)
SELECT n,
       CONCAT(N'Customer ', n),
       DATEADD(SECOND, n, '2020-01-01')
FROM Nums;

WITH Nums AS (
    SELECT TOP (1000000) ROW_NUMBER() OVER (ORDER BY a.object_id) AS n
    FROM sys.all_objects a CROSS JOIN sys.all_objects b
)
INSERT INTO dbo.Sloppy (CustomerId, Name, CreatedAt)
SELECT CAST(n AS NVARCHAR(50)),
       CONCAT(N'Customer ', n),
       DATEADD(SECOND, n, '2020-01-01')
FROM Nums;

-- Confronta le dimensioni
SELECT OBJECT_NAME(ps.object_id) AS table_name,
       SUM(ps.reserved_page_count) * 8 / 1024 AS reserved_mb
FROM sys.dm_db_partition_stats ps
WHERE ps.object_id IN (OBJECT_ID('dbo.WellTyped'), OBJECT_ID('dbo.Sloppy'))
GROUP BY ps.object_id;

-- Query sulla colonna CustomerId — tipo non corrispondente
SET STATISTICS IO, TIME ON;

SELECT Name FROM dbo.WellTyped WHERE CustomerId = 12345;
SELECT Name FROM dbo.Sloppy    WHERE CustomerId = '12345';  -- stringa contro stringa

SET STATISTICS IO, TIME OFF;

-- Pulizia
DROP TABLE dbo.WellTyped;
DROP TABLE dbo.Sloppy;

Eseguilo. Nota la differenza di spazio riservato, i tempi di query, le statistiche IO. La tabella sciatta sarà significativamente più grande. In un sistema reale con 500 milioni di righe, la differenza è la differenza tra rientrare nella tua finestra di backup e non rientrarci.

Prossima lezione: CREATE, ALTER, DROP — fare e rompere le tabelle, con pattern pratici e l’esercizio “oh no ho droppato la cosa sbagliata”.

Cerca