Null virando zero: JS, PHP, Python, C# e SQL testados
Onde null, vazio e campo ausente viram 0 em JavaScript, PHP, Python, C#, MySQL e PostgreSQL, com leitura de preço segura e o CHECK que deixa NULL passar.
Zero é uma medida: alguém olhou e deu zero. Ausência é outra coisa: o dado não veio. Quando o código troca uma pela outra, o resultado é um número válido, que passa por todas as validações de tipo e só aparece como problema quando um produto é vendido por R$ 0,00 ou uma média cai sem motivo. Este guia é uma referência das conversões que fazem isso em JavaScript, PHP, Python, C#, MySQL e PostgreSQL, todas rodadas no Node 20, PHP 8.4.27, Python 3, .NET 10, MySQL 8.4.13 e PostgreSQL 18.6, seguida de uma leitura de preço que não chuta e de uma tabela que recusa o zero acidental.
JavaScript: o operador || apaga o aviso
Number(null) // 0
Number("") // 0
Number(" ") // 0
Number(undefined) // NaN
parseFloat("12,90") // 12 (a vírgula encerra a leitura)
Number(undefined) || 0 // 0 (o NaN que denunciava o erro sumiu)
0 || 10 // 10 (desconto zero virou dez)
0 ?? 10 // 0
null ?? 0 // 0 (?? corrige o desconto, mas ainda inventa preço)
JSON.stringify({ preco: NaN, desconto: undefined }) // {"preco":null}
Math.round(1.005 * 100) // 100, não 101
Dois detalhes pesam em sistema brasileiro. parseFloat("12,90") para na vírgula e devolve 12, sem erro, então uma planilha exportada em português vira preço truncado. E JSON.stringify transforma NaN em null e some com undefined, então o problema muda de cara quando o objeto atravessa a API. O operador ?? resolve o caso do desconto legítimo de zero, porque só substitui null e undefined, mas não decide nada por você: item.preco ?? 0 continua inventando preço. As regras de conversão do Number() estão na referência do Number no MDN.
A última linha não é sobre nulo, mas aparece no mesmo código de importação: 1.005 * 100 em ponto flutuante dá 100.49999…, e o arredondamento vai para 100. Converter preço multiplicando float por 100 perde centavo. Por isso a leitura mais abaixo trabalha com o texto. O guia de dinheiro em centavos explica por que guardar em inteiros.
PHP: conversão de tipo sem reclamação
intval(null) // int(0)
(int) "" // int(0)
(float) "12,90" // float(12)
(int) "abc" // int(0)
(int) ($item["preco"] ?? 0) // int(0) com o campo ausente
filter_var("", FILTER_VALIDATE_INT) // bool(false)
filter_var("0", FILTER_VALIDATE_INT) // int(0)
As conversões de tipo do PHP transformam nulo, texto vazio e texto qualquer em 0, e "12,90" em 12. O filter_var com FILTER_VALIDATE_INT é o que separa os casos: vazio devolve false, e o zero digitado devolve 0. Compare com === false, nunca com !, ou o zero legítimo cai no mesmo balde do erro.
Python: or tem o mesmo defeito, o resto reclama
item = {"sku": "FN-349", "desconto": 0}
item.get("preco") or 0 # 0
item.get("desconto") or 10 # 10
float("12,90") # ValueError: could not convert string to float: '12,90'
int(None) # TypeError
json.loads('{"preco": NaN}') # {'preco': nan}
O or segue as regras de valor verdade: 0, None e texto vazio são falsos, então desconto or 10 estraga o zero. Em compensação, float("12,90") e int(None) levantam exceção, o que é bom. Um cuidado menos conhecido: o módulo json aceita NaN, que não faz parte do padrão JSON, e devolve nan, que passa em isinstance(x, float).
C#: decimal ausente vira 0 ao desserializar
using System.Text.Json;
var json = """{"sku":"FN-349"}""";
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
JsonSerializer.Deserialize<ItemSimples>(json, web)!.Preco; // 0
JsonSerializer.Deserialize<ItemAnulavel>(json, web)!.Preco; // null
var estrito = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
RespectRequiredConstructorParameters = true,
};
JsonSerializer.Deserialize<ItemSimples>(json, estrito);
// JsonException: JSON deserialization for type 'ItemSimples' was missing
// required properties including: 'preco'.
record ItemSimples(string Sku, decimal Preco);
record ItemAnulavel(string Sku, decimal? Preco);
Com System.Text.Json, propriedade decimal que não veio no JSON fica com o valor padrão do tipo, que é zero. decimal? preserva a ausência como null. E desde o .NET 9 a opção RespectRequiredConstructorParameters faz o parâmetro de construtor de um record virar obrigatório, o que transforma a ausência em exceção logo na entrada. Para classes, a palavra-chave required tem o mesmo efeito. As duas formas estão na documentação de propriedades obrigatórias.
MySQL: o modo do servidor decide se o zero aparece
CREATE TABLE produtos (sku VARCHAR(20) PRIMARY KEY, preco_centavos INT NOT NULL);
SET SESSION sql_mode = ''; -- comum em servidor antigo
INSERT INTO produtos (sku) VALUES ('FN-349'); -- grava 0
INSERT INTO produtos VALUES ('CB-USB', ''); -- grava 0
SET SESSION sql_mode = DEFAULT; -- STRICT_TRANS_TABLES ligado
INSERT INTO produtos (sku) VALUES ('MS-110');
-- ERROR 1364 (HY000): Field 'preco_centavos' doesn't have a default value
Sem o modo estrito, o MySQL grava 0 numa coluna NOT NULL sem default quando o INSERT esquece o campo, e converte texto vazio em 0, deixando só um aviso que quase ninguém lê. O modo padrão do 8.4 inclui STRICT_TRANS_TABLES e recusa o insert. Servidor migrado de versão antiga ou hospedagem compartilhada pode estar com o modo vazio: confira com SELECT @@GLOBAL.sql_mode;. E um DEFAULT 0 explícito na coluna faz o mesmo estrago em qualquer modo.
Agregação: NULL fica de fora, e SUM vazio é NULL
-- notas 10, NULL (não avaliou) e 8 SELECT AVG(nota), AVG(COALESCE(nota, 0)), COUNT(*), COUNT(nota) FROM avaliacoes; -- 9.0000 | 6.0000 | 3 | 2 -- produto sem nenhuma avaliação SELECT SUM(nota), COALESCE(SUM(nota), 0) FROM avaliacoes WHERE produto = 2; -- (null) | 0
As funções de agregação ignoram NULL, e isso é o correto: a média de quem avaliou é 9. Trocar NULL por zero antes de agregar muda a resposta para 6. No sentido contrário, SUM sobre nenhuma linha devolve NULL, não zero. Ali o COALESCE(SUM(...), 0) faz sentido, porque a soma de nada é mesmo zero. A regra prática: COALESCE depois de agregar costuma estar certo, antes de agregar costuma estar errado.
Uma leitura de preço que não chuta
Juntando tudo, a leitura de um preço vindo de fornecedor precisa separar três respostas: não veio, veio inválido e veio zero. Esta versão também aceita o formato brasileiro e não passa por ponto flutuante:
// Lê o preço que veio do fornecedor e devolve centavos, ou o motivo de não ter preço.
// Aceita "349.90", "349,90", 349.9 e "1.299,00"; recusa vazio, texto, negativo e zero sem brinde.
function lerPrecoCentavos(item) {
const bruto = item.preco;
if (bruto === undefined || bruto === null || (typeof bruto === 'string' && bruto.trim() === '')) {
return { ok: false, motivo: 'sem preço' };
}
let texto = typeof bruto === 'number' ? String(bruto) : bruto.trim();
if (/^\d{1,3}(\.\d{3})*,\d{1,2}$/.test(texto) || /^\d+,\d{1,2}$/.test(texto)) {
texto = texto.replace(/\./g, '').replace(',', '.'); // formato brasileiro
}
const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(texto);
if (!m) return { ok: false, motivo: 'preço inválido: ' + JSON.stringify(bruto) };
const centavos = Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
if (centavos === 0 && item.brinde !== true) {
return { ok: false, motivo: 'preço zero sem marcação de brinde' };
}
return { ok: true, centavos };
}
const casos = [
{ sku: 'FN-349', preco: '349,90' },
{ sku: 'TV-55', preco: '1.299,00' },
{ sku: 'CB-USB', preco: 1.005 },
{ sku: 'MS-110' },
{ sku: 'PL-02', preco: ' ' },
{ sku: 'AD-12', preco: 'R$ 12' },
{ sku: 'BR-01', preco: 0, brinde: true },
{ sku: 'KB-77', preco: 0 },
];
for (const c of casos) console.log(c.sku.padEnd(7), JSON.stringify(lerPrecoCentavos(c)));
FN-349 {"ok":true,"centavos":34990}
TV-55 {"ok":true,"centavos":129900}
CB-USB {"ok":false,"motivo":"preço inválido: 1.005"}
MS-110 {"ok":false,"motivo":"sem preço"}
PL-02 {"ok":false,"motivo":"sem preço"}
AD-12 {"ok":false,"motivo":"preço inválido: \"R$ 12\""}
BR-01 {"ok":true,"centavos":0}
KB-77 {"ok":false,"motivo":"preço zero sem marcação de brinde"}
O 1.005 foi recusado em vez de arredondado, porque preço com três casas é dado suspeito. "R$ 12" também, porque limpar símbolo de moeda é uma decisão que deve ficar explícita, não um efeito de parseFloat. Item recusado não deve ser descartado nem vendido: ele vai para um estado próprio, como sem_preco, com o motivo gravado para quem cuida do catálogo. E essa validação vale no servidor mesmo que a tela já valide, pelo motivo explicado em validar no navegador e repetir no servidor.
A tabela que recusa o zero acidental, e a pegadinha do CHECK
No banco, a primeira ideia é tirar o DEFAULT 0, permitir NULL para o item sem preço e criar uma regra que impeça produto ativo sem preço. Testado no PostgreSQL, a primeira versão da regra deixou passar justamente o caso que devia barrar:
ALTER TABLE produtos ALTER COLUMN preco_centavos DROP DEFAULT;
ALTER TABLE produtos ALTER COLUMN preco_centavos DROP NOT NULL;
ALTER TABLE produtos ADD CONSTRAINT preco_para_vender CHECK (
status <> 'ativo' OR preco_centavos > 0 OR (preco_centavos = 0 AND brinde)
);
INSERT INTO produtos (sku) VALUES ('KB-77'); -- passou: preço NULL, status ativo
A documentação de restrições explica: um CHECK é satisfeito quando a expressão dá verdadeiro ou nulo, e qualquer comparação com NULL dá nulo. A regra precisa tratar a ausência explicitamente:
ALTER TABLE produtos ADD CONSTRAINT preco_para_vender CHECK (
status <> 'ativo'
OR (preco_centavos IS NOT NULL AND (preco_centavos > 0 OR brinde))
);
INSERT INTO produtos (sku, status) VALUES ('MS-110', 'sem_preco'); -- ok
INSERT INTO produtos (sku, preco_centavos, brinde) VALUES ('BR-01', 0, true); -- ok
INSERT INTO produtos (sku) VALUES ('KB-77');
-- ERROR: new row for relation "produtos" violates check constraint "preco_para_vender"
Agora o item sem preço pode existir, desde que não esteja à venda. O brinde de zero continua permitido porque alguém o registrou como brinde. E o insert que esquece o preço de um produto ativo falha, que é o comportamento que se quer.
O teste que pega antes do fornecedor
Os casos da lista acima viram testes: campo ausente, nulo, texto vazio, espaço, vírgula, três casas, símbolo de moeda, zero com e sem brinde. Escreva o teste do campo ausente antes de corrigir o código e veja ele falhar. Para entradas digitadas na tela, o guia sobre campo que aceita só números trata do lado do formulário.
Referência: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing