Null safety
Escopo: SQL. Visão transversal: shared/standards/null-safety.md.
NULL em SQL (Structured Query Language · Linguagem de Consulta Estruturada) significa "o valor é desconhecido". Ele ocupa uma categoria própria, separada de 0, de false e da string vazia, e é daí que vem a maior parte das surpresas.
A consequência prática está nas comparações. Perguntar se um valor desconhecido é igual a 10 devolve outro desconhecido: a resposta não é verdadeira nem falsa. Em SQL isso se chama three-valued logic (lógica de três valores): toda comparação devolve TRUE, FALSE ou UNKNOWN, e o WHERE só deixa passar a linha quando a resposta é TRUE.
Os exemplos seguem a convenção SQL Server (PascalCase). Exemplos específicos de PostgreSQL são marcados com
-- PostgreSQL.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| NULL (ausência de valor) | Marca que o valor é desconhecido; categoria própria, separada de 0, false e string vazia |
| three-valued logic (lógica de três valores) | TRUE, FALSE, UNKNOWN; toda comparação com NULL devolve UNKNOWN |
| IS NULL / IS NOT NULL (é nulo / não é nulo) | Os operadores que testam a presença de NULL; = NULL nunca devolve linha |
| COALESCE (coalescência) | Retorna o primeiro argumento não-nulo; substitui NULL por valor padrão |
| NULLIF (anular se igual) | Retorna NULL quando dois argumentos são iguais; útil para evitar divisão por zero |
| NOT NULL constraint (restrição NOT NULL) | Garante que a coluna nunca aceita NULL; aplicar quando o domínio exige presença |
| NULL-safe equals (igualdade segura contra NULL) | IS NOT DISTINCT FROM (PostgreSQL) ou INTERSECT (SQL Server); compara tratando NULL como valor |
Use IS NULL, porque = NULL nunca devolve linha
WHERE Coach = NULL pergunta se um valor desconhecido é igual a um valor desconhecido, e a resposta é UNKNOWN. Como o WHERE só aceita a linha quando a resposta é TRUE, a query devolve zero linhas, mesmo com a tabela cheia de times sem técnico. Nenhum erro aparece, o resultado vem vazio, e a query parece certa.
IS NULL e IS NOT NULL são os operadores que perguntam pela presença do valor, e eles respondem TRUE ou FALSE.
❌ Ruim: = NULL não retorna nenhuma linha
SELECT *
FROM orders
WHERE assigned_to = NULL; -- retorna 0 linhas sempre
SELECT *
FROM orders
WHERE assigned_to != NULL; -- retorna 0 linhas sempre
✅ Bom: IS NULL / IS NOT NULL
SELECT
Orders.Id,
Orders.Status,
Orders.AssignedTo
FROM
Orders
WHERE
Orders.AssignedTo IS NULL;
SELECT
Orders.Id,
Orders.Status,
Orders.AssignedTo
FROM
Orders
WHERE
Orders.AssignedTo IS NOT NULL;
-- combinando com outras condições
SELECT
Orders.Id,
Orders.Status,
Orders.AssignedTo
FROM
Orders
WHERE
Orders.Status = 'Pending' AND
Orders.AssignedTo IS NULL;
COALESCE devolve o primeiro valor que existir
COALESCE(Nickname, FirstName, 'Anonymous') percorre os argumentos da esquerda para a direita e devolve o primeiro que tiver valor. Ele resolve numa linha o que o CASE WHEN resolve em cinco, e a lista de alternativas cresce acrescentando um argumento.
O mesmo vale para conta: Total + Bonus devolve NULL inteiro se o bônus for desconhecido, porque qualquer operação com NULL resulta em NULL. Total + COALESCE(Bonus, 0) trata a ausência de bônus como zero e a soma volta a fazer sentido.
❌ Ruim: CASE WHEN para fallback: verboso e difícil de encadear
-- fallback com CASE WHEN: repetitivo para cada nível
SELECT
UserId,
CASE
WHEN Nickname IS NOT NULL THEN Nickname
WHEN FirstName IS NOT NULL THEN FirstName
ELSE 'Anonymous'
END AS DisplayName
FROM Users;
-- cálculo sem proteção: NULL Discount = NULL Total
SELECT
OrderId,
Discount + BaseAmount AS Total -- NULL se Discount for NULL
FROM Orders;
✅ Bom: fallback e cálculo null-safe
-- fallback em cascata
SELECT
Users.Id,
COALESCE(Users.Nickname, Users.FirstName, 'Anonymous') AS DisplayName
FROM
Users;
-- cálculo null-safe
SELECT
Orders.Id,
COALESCE(Orders.Discount, 0) + Orders.BaseAmount AS Total -- NULL Discount = NULL Total sem COALESCE
FROM
Orders;
-- normalizar dados legados
SELECT
Products.Id,
COALESCE(Products.NewPrice, Products.LegacyPrice, 0.00) AS Price
FROM
Products;
NULLIF transforma um valor específico em NULL
NULLIF(a, b) devolve NULL quando a e b são iguais, e devolve a no resto dos casos. Ele serve a dois usos frequentes.
O primeiro é a divisão por zero. Amount / NULLIF(Quantity, 0) transforma o divisor zero em NULL, e a divisão devolve NULL no lugar de derrubar a query com erro. O segundo é a string vazia: NULLIF(Nickname, '') faz o apelido em branco valer como ausente, e aí o COALESCE que vem depois consegue cair no próximo valor.
❌ Ruim: CASE WHEN para divisão segura e normalização: mais verboso
-- divisão por zero com CASE
SELECT
OrderId,
CASE
WHEN Quantity = 0 THEN NULL
ELSE Amount / Quantity
END AS UnitPrice
FROM Orders;
-- string vazia tratada manualmente
SELECT
UserId,
CASE
WHEN TRIM(PhoneNumber) = '' THEN NULL
ELSE TRIM(PhoneNumber)
END AS PhoneNumber
FROM Users;
✅ Bom: divisão segura e normalização de string vazia
-- divisão por zero sem CASE
SELECT
Orders.Id,
Orders.Amount / NULLIF(Orders.Quantity, 0) AS UnitPrice -- NULL se Quantity for 0
FROM
Orders;
-- tratar string vazia como NULL
SELECT
Users.Id,
NULLIF(TRIM(Users.PhoneNumber), '') AS PhoneNumber -- '' vira NULL
FROM
Users;
Barrar o NULL no schema poupa COALESCE em toda query
A coluna que aceita NULL obriga cada query a se defender. Toda soma precisa de COALESCE, todo filtro precisa lembrar do caso ausente, e basta um esquecimento para o resultado sair errado em silêncio.
Declarar NOT NULL com um DEFAULT resolve na origem: a coluna nasce com valor mesmo quando o INSERT não a menciona. Quando o domínio exige que o valor exista (todo pedido tem status, todo registro tem data de criação), essa é a declaração certa.
❌ Ruim: coluna nullable sem default obriga COALESCE em todo lugar
CREATE TABLE Orders
(
Id INT,
Status VARCHAR(20), -- nullable, sem default
Priority VARCHAR(20), -- nullable, sem default
CreatedAt DATETIME2 -- nullable, sem default
);
-- toda query precisa se defender
SELECT
Orders.Id,
COALESCE(Orders.Status, 'unknown') AS Status,
COALESCE(Orders.Priority, 'normal') AS Priority
FROM
Orders;
✅ Bom: NOT NULL + DEFAULT fecha o problema na origem
CREATE TABLE Orders
(
Id INT NOT NULL IDENTITY(1, 1),
Status NVARCHAR(20) NOT NULL DEFAULT 'Pending',
Priority NVARCHAR(20) NOT NULL DEFAULT 'Normal',
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT PK_Orders PRIMARY KEY (Id)
);
-- queries simples, sem defesa
SELECT
Orders.Id,
Orders.Status,
Orders.Priority
FROM
Orders;
As agregações pulam o NULL, e o AVG mente por causa disso
SUM, AVG, MIN e MAX descartam as linhas nulas antes de calcular. Isso importa muito no AVG: a média de 10, 20 e NULL é 15, porque o divisor é 2. Se aquele NULL deveria valer zero, a média real era 10, e a query devolveu 15 sem avisar.
A diferença entre as duas formas de contar segue a mesma lógica. COUNT(*) conta as linhas da tabela. COUNT(AssignedTo) conta as linhas em que AssignedTo tem valor. Trocar uma pela outra muda o número do relatório.
❌ Ruim: assumir que COUNT(*) e COUNT(coluna) são equivalentes
-- COUNT(*) conta nulos: o resultado pode enganar
SELECT
Status,
COUNT(*) AS AssignedOrders -- inclui linhas onde AssignedTo IS NULL
FROM Orders
GROUP BY Status;
-- SUM pode retornar NULL quando não há linhas no grupo
SELECT
TeamId,
SUM(Salary) AS TotalSalary -- retorna NULL se não houver funcionários
FROM Employees
GROUP BY TeamId;
✅ Bom: comportamento de NULL em agregações
-- COUNT(*) vs COUNT(coluna)
SELECT
Orders.Status,
COUNT(*) AS TotalOrders, -- conta todas as linhas
COUNT(Orders.AssignedTo) AS AssignedOrders, -- ignora NULL
COUNT(DISTINCT Orders.CustomerId) AS Customers
FROM
Orders
GROUP BY
Orders.Status;
-- AVG ignora NULL: divisor é count de não-nulos
SELECT
Reviews.ProductId,
AVG(Reviews.Rating) AS AvgRating, -- apenas ratings preenchidos
COUNT(Reviews.Rating) AS RatingCount -- quantos avaliaram
FROM
Reviews
GROUP BY
Reviews.ProductId;
-- garantir resultado 0 em vez de NULL quando não há linhas
SELECT
Employees.TeamId,
COALESCE(SUM(Employees.Salary), 0) AS TotalSalary,
COUNT(*) AS Headcount
FROM
Employees
GROUP BY
Employees.TeamId;
A linha com chave nula desaparece do INNER JOIN
O JOIN compara a chave dos dois lados, e a comparação com NULL devolve UNKNOWN. O pedido cujo CustomerId está nulo não casa com cliente nenhum, e o INNER JOIN o descarta. O relatório de pedidos vem com menos pedidos do que a tabela tem, e nada na saída indica que faltou alguém.
Declarar a chave estrangeira como NOT NULL fecha o caso na origem. Quando a ausência é legítima (um pedido de balcão sem cliente cadastrado), o LEFT JOIN preserva a linha e deixa as colunas do cliente nulas.
❌ Ruim: JOIN com chave nullable perde linhas silenciosamente
-- se CustomerId for NULL em algum pedido, a linha some no INNER JOIN
SELECT
o.Id,
c.Name AS CustomerName
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id;
-- LEFT JOIN traz a linha, mas CustomerName será NULL: difícil de depurar
✅ Bom: chave estrangeira NOT NULL, comportamento previsível
CREATE TABLE Orders
(
Id INT NOT NULL IDENTITY(1, 1),
CustomerId INT NOT NULL,
Status NVARCHAR(20) NOT NULL DEFAULT 'Pending',
CONSTRAINT PK_Orders PRIMARY KEY (Id),
CONSTRAINT FK_Orders_Customers FOREIGN KEY (CustomerId)
REFERENCES Customers (Id)
);
-- JOIN previsível: CustomerId sempre existe
SELECT
Orders.Id,
Customers.Name AS CustomerName
FROM
Orders
JOIN
Customers ON Orders.CustomerId = Customers.Id;
Um NULL na subquery faz o NOT IN devolver zero linhas
Essa é a mais traiçoeira da página. WHERE Id NOT IN (SELECT UserId FROM Users) pergunta, para cada pedido, se o identificador é diferente de todos os valores da lista. Se um dos valores da lista é NULL, a comparação com ele devolve UNKNOWN, e a resposta final nunca chega a TRUE. A query devolve vazio, sempre, mesmo com as duas tabelas cheias.
NOT EXISTS faz a mesma pergunta e não cai nessa armadilha, porque ele testa a existência da linha em vez de comparar valores. Use NOT EXISTS por padrão.
❌ Ruim: NOT IN retorna vazio se a subquery contiver NULL
-- se Users tiver algum Id NULL, essa query retorna 0 linhas
SELECT
Orders.Id,
Orders.Status
FROM
Orders
WHERE
Orders.CustomerId NOT IN (SELECT Users.Id FROM Users);
✅ Bom: filtrar NULL da subquery ou usar NOT EXISTS
-- opção 1: filtrar NULL explicitamente
SELECT
Orders.Id,
Orders.Status
FROM
Orders
WHERE
Orders.CustomerId NOT IN (
SELECT Users.Id FROM Users WHERE Users.Id IS NOT NULL
);
-- opção 2: NOT EXISTS: null-safe por design
SELECT
Orders.Id,
Orders.Status
FROM
Orders
WHERE
NOT EXISTS (
SELECT 1 FROM Users WHERE Users.Id = Orders.CustomerId
);
A coluna UNIQUE aceita vários NULL
A restrição UNIQUE impede valores repetidos, e como um NULL nunca é considerado igual a outro NULL, ela deixa passar quantas linhas nulas você quiser. Uma coluna de CPF UNIQUE aceita mil usuários sem CPF, e isso costuma ser o que se quer.
Quando a regra é "único entre os que têm valor", declare um índice filtrado (WHERE Cpf IS NOT NULL). Assim a intenção fica escrita no schema.
❌ Ruim: intenção de "único quando preenchido" não está declarada explicitamente
CREATE TABLE Users
(
Id INT NOT NULL IDENTITY(1, 1),
Email VARCHAR(255) NOT NULL,
Phone VARCHAR(20) UNIQUE NULL, -- intenção ambígua
CONSTRAINT PK_Users PRIMARY KEY (Id)
);
✅ Bom: índice filtrado declara explicitamente a intenção
CREATE TABLE Users
(
Id INT NOT NULL IDENTITY(1, 1),
Email VARCHAR(255) NOT NULL,
Phone VARCHAR(20) NULL,
CONSTRAINT PK_Users PRIMARY KEY (Id),
CONSTRAINT UQ_Users_Email UNIQUE (Email)
);
-- SQL Server: índice filtrado para unicidade apenas em valores preenchidos
CREATE UNIQUE NONCLUSTERED INDEX UQ_Users_Phone_NotNull
ON Users (Phone)
WHERE Phone IS NOT NULL;
-- PostgreSQL: índice parcial equivalente
CREATE UNIQUE INDEX uq_users_phone_not_null
ON users (phone)
WHERE phone IS NOT NULL;
IS DISTINCT FROM compara tratando NULL como um valor
No PostgreSQL, IS DISTINCT FROM responde TRUE ou FALSE mesmo quando um dos lados é NULL. Dois nulos contam como iguais, e um nulo contra um texto conta como diferente.
Isso resolve a comparação de auditoria. Para detectar que o status mudou de NULL para 'shipped', o <> comum devolve UNKNOWN e a linha some do resultado, então a mudança passa despercebida. IS DISTINCT FROM devolve TRUE e a mudança aparece.
❌ Ruim: comparação sem IS DISTINCT FROM perde mudanças envolvendo NULL
-- NULL != 'shipped' retorna NULL: linha ignorada no WHERE, mudança some silenciosamente
SELECT
OrderId
FROM
OrderHistory
WHERE
NewStatus != OldStatus;
✅ Bom: IS DISTINCT FROM detecta qualquer mudança, incluindo de/para NULL
-- PostgreSQL
SELECT
order_history.order_id
FROM
order_history
WHERE
order_history.new_status IS DISTINCT FROM order_history.old_status;
-- SQL Server: equivalente sem IS DISTINCT FROM
SELECT
OrderHistory.Id
FROM
OrderHistory
WHERE
OrderHistory.NewStatus != OrderHistory.OldStatus OR
(OrderHistory.NewStatus IS NULL AND OrderHistory.OldStatus IS NOT NULL) OR
(OrderHistory.NewStatus IS NOT NULL AND OrderHistory.OldStatus IS NULL);
Cada banco põe o NULL num lugar diferente da ordenação
Ordenando em ordem crescente, o PostgreSQL joga os nulos para o fim e o SQL Server os joga para o começo. A mesma query, o mesmo dado, duas telas diferentes. Isso morde quem desenvolve num banco e roda em produção no outro.
O PostgreSQL aceita NULLS FIRST e NULLS LAST no ORDER BY. No SQL Server, a posição se declara com uma coluna auxiliar no ORDER BY que ordena primeiro pelo CASE WHEN ... IS NULL.
❌ Ruim: ORDER BY sem controle de NULL: posição varia por banco
-- PostgreSQL: NULL vai para o fim em ASC (NULLS LAST implícito)
-- SQL Server: NULL vai para o início em ASC
-- resultado diferente no mesmo código
SELECT
Orders.Id,
Orders.DueDate
FROM
Orders
ORDER BY
Orders.DueDate ASC;
✅ Bom: controle explícito da posição de NULL na ordenação
-- PostgreSQL: NULLS FIRST / NULLS LAST
SELECT
orders.id,
orders.due_date
FROM
orders
ORDER BY
orders.due_date ASC NULLS LAST; -- sem data ficam no fim
-- SQL Server: CASE para simular NULLS LAST
SELECT
Orders.Id,
Orders.DueDate
FROM
Orders
ORDER BY
CASE WHEN Orders.DueDate IS NULL THEN 1 ELSE 0 END,
Orders.DueDate ASC;
A coluna nova numa tabela com dados precisa de DEFAULT
ALTER TABLE Orders ADD Priority NVARCHAR(20) NOT NULL funciona na tabela vazia e falha na tabela com dados. A migration para na hora: os pedidos que já existem não têm valor para a coluna nova, e a restrição NOT NULL proíbe deixá-los nulos.
Declarar o DEFAULT junto resolve, porque o banco preenche as linhas antigas com ele. A estratégia completa, para quando o valor padrão não serve, está em Segurança contra nulos: campo novo em tabela existente.
❌ Ruim: NOT NULL sem DEFAULT: migration falha em tabelas com dados
-- falha se a tabela já tiver registros: registros existentes não têm valor para a nova coluna
-- SQL Server
ALTER TABLE Orders ADD Priority NVARCHAR(20) NOT NULL;
-- PostgreSQL
ALTER TABLE orders ADD COLUMN priority VARCHAR(20) NOT NULL;
✅ Bom: DEFAULT garante que registros antigos nunca ficam NULL
-- SQL Server: uma instrução, registros antigos recebem 'Normal'
ALTER TABLE Orders ADD Priority NVARCHAR(20) NOT NULL DEFAULT 'Normal';
-- PostgreSQL: equivalente
ALTER TABLE orders ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'normal';
✅ Bom: migration em lotes para tabelas grandes em produção
-- SQL Server
-- passo 1: adiciona nullable para não bloquear a tabela
ALTER TABLE Orders ADD Priority NVARCHAR(20) NULL;
-- passo 2: preenche os registros existentes em lote
UPDATE Orders SET Orders.Priority = 'Normal' WHERE Orders.Priority IS NULL;
-- passo 3: aplica o constraint depois que todos os registros têm valor
ALTER TABLE Orders ALTER COLUMN Priority NVARCHAR(20) NOT NULL;
ALTER TABLE Orders ADD DEFAULT 'Normal' FOR Priority;
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.