Noções de segurança
Erro do Supabase "null value in column violates not-null constraint"
Supabase "null value in column violates not-null constraint": uma coluna obrigatória chegou vazia. De onde veio o null, e como corrigir.

Em resumo
- Supabase "null value in column violates not-null constraint" (código do Postgres 23502, HTTP 400 no seu app) significa que uma linha chegou sem nada numa coluna que sempre precisa ter um valor. A mensagem nomeia a coluna e a tabela. Nada foi salvo.
- O valor vazio vem de um campo que seu app nunca envia, de um null que seu código envia de propósito ou de uma coluna sem o valor padrão que você supunha. Um upsert e uma inserção em lote podem produzi-lo numa linha que parece completa.
- Mantenha a regra e corrija a linha. Do código de servidor, nunca devolva o erro cru ao navegador: a linha "Failing row contains" dele pode mostrar a linha inteira.
Você aperta Salvar numa tarefa nova do seu app e nada é salvo. A página não mostra erro nenhum e a lista continua igual. O console do navegador tem o motivo:
{
"code": "23502",
"details": null,
"hint": null,
"message": "null value in column \"category_id\" of relation \"tasks\" violates not-null constraint"
}
Supabase "null value in column violates not-null constraint" significa que uma
linha chegou ao seu banco sem nada numa coluna que sempre precisa ter um valor, e
o Postgres recusou a linha inteira. Aqui a coluna é category_id na sua tabela
tasks. O salvamento não mandou nenhuma categoria.
A correção mais rápida é remover a regra: uma linha de SQL e a coluna pode ficar vazia. O erro some, e daí em diante chegam linhas com esse campo em branco, que o resto do seu app encontra onde você não estava olhando. Descubra primeiro de onde veio o valor vazio. Quase sempre é um de poucos lugares.
O que significa "null value in column violates not-null constraint"?
Significa que uma coluna da linha que seu app quis salvar estava vazia, que essa coluna nunca pode ficar vazia, e que o Postgres se recusou a salvar qualquer parte da linha. Nada foi gravado. Null é a palavra do Postgres para nenhum valor, o que não é o mesmo que um zero nem que um texto sem letras.
Pense em cada linha como um formulário de papel e na tabela como a gaveta do arquivo onde ele é guardado. Uma restrição not-null é um asterisco impresso ao lado de um campo. O Postgres fica na gaveta e não arquiva nenhum formulário com um campo de asterisco em branco. O formulário volta, e a mensagem diz qual campo.
| Campo | Exemplo | O que ele diz |
|---|---|---|
code | 23502 | O código do Postgres para esse erro, chamado not_null_violation na lista de códigos dele |
message | null value in column "category_id" of relation "tasks" violates not-null constraint | A coluna vazia e depois a tabela. "Relation" é a palavra do Postgres para tabela |
details | Failing row contains (…), ou null | Uma cópia da linha recusada, quando o Postgres decide que quem chamou pode vê-la. Uma seção mais abaixo explica quando |
hint | null | Nada a acrescentar para esse erro |
| Status HTTP | 400 Bad Request | O PostgREST, a parte do Supabase que transforma chamadas supabase.from(...) em SQL, não tem um caso próprio para 23502, então o código dele cai em 400 |
O texto é do próprio Postgres, da função que dispara o erro, e se lê igual venha a linha do seu app, de um trigger ou do SQL Editor.
De onde veio o valor vazio?
Geralmente de um de três lugares no código do seu próprio app, e a coluna da mensagem diz onde olhar primeiro. Outros dois começam dentro do banco.
Um campo que seu app nunca envia. É o caso mais comum. A coluna virou
obrigatória depois que o formulário foi feito: um construtor com IA adicionou
category_id à tabela quando você pediu categorias, marcou como obrigatória e
deixou como estava o formulário que salva as tarefas. Nada na requisição nomeia a
coluna, a coluna não tem valor padrão, então ela fica vazia. Compare as colunas no
seu Table Editor com o objeto que seu app passa para .insert(). A que falta é a
da mensagem.
Uma chave escrita diferente da coluna nem chega até aqui. O PostgREST recusa
categoryId enviado para uma coluna chamada category_id antes que o Postgres
veja a linha, e
esse erro tem um artigo próprio.
Um null que seu código envia de propósito. { category_id: selected?.id ?? null }
manda um null explícito quando nada está selecionado, e um null explícito é um
valor para a requisição. O undefined do JavaScript se comporta de outro jeito.
JSON não tem esse valor, então o
JSON.stringify
o deixa "omitted (when found in an object)", e uma chave com undefined nunca
chega ao Supabase. Qual dos dois seu código produz decide se um valor padrão pode
entrar, e a próxima seção trata disso.
Uma coluna sem o valor padrão que você supunha. created_at costuma se
preencher sozinha, porque a tabela diz default now(). user_id normalmente não,
porque nada no Postgres sabe quem está logado até alguém dizer à coluna como
descobrir. Uma seção mais adiante mostra como.
Um cadastro, sob outra mensagem. Um trigger que copia cada usuário novo para
uma tabela profiles lê os dados do cadastro com ->>, e
os operadores JSON do Postgres
"return NULL, rather than failing, if the JSON input does not have the right
structure to match the request; for example if no such key or array element
exists". Um formulário de cadastro que nunca mandou full_name dá ao trigger um
nome vazio, uma coluna obrigatória full_name recusa, e o Supabase reporta o
cadastro inteiro como
"Database error saving new user". O
23502 em si está nos logs do Postgres, e esse artigo mostra onde.
Uma exclusão em outro lugar. Uma chave estrangeira com on delete set null
esvazia a coluna em cada linha que apontava para a excluída. Se essa coluna também
é obrigatória, quem falha com esta mensagem é a exclusão, e
o artigo sobre excluir usuários explica como
escolher uma regra que sirva.
"Mas a coluna tem um valor padrão"
Então a requisição nomeou a coluna e a mandou vazia. O Postgres aplica um valor padrão só quando a coluna fica totalmente de fora da linha. A documentação dele sobre valores padrão diz: "When a new row is created and no values are specified for some of the columns, those columns will be filled with their respective default values." Um null explícito conta como valor informado. No formulário, um campo com valor pré-impresso conta como preenchido até alguém escrever "nenhum" por cima da impressão.
O supabase-js escreve "nenhum" por cima num lugar que você não esperaria: uma
inserção em lote, em que você passa um array de linhas. Ele nomeia todas as
colunas que aparecem em qualquer uma das linhas, e
o código-fonte dele
documenta a opção que decide o que acontece com as lacunas, defaultToNull:
"Make missing fields default to null. Otherwise, use the default value for the
column. Only applies for bulk inserts." Ela vale true enquanto você não mudar.
A documentação do PostgREST
diz o mesmo para uma requisição que lista as próprias colunas, que é o que o
supabase-js manda com um array: "any missing columns in the payload will be
inserted as null value by default."
Então aqui a segunda tarefa entra com status vazio, mesmo que a tabela dê a
status o valor padrão 'todo', e um status obrigatório recusa a inserção
inteira:
await supabase.from('tasks').insert([
{ title: 'Escrever o briefing', status: 'doing' },
{ title: 'Enviar a fatura' },
])
Qualquer uma das duas correções funciona. Mande status em todas as linhas, ou
diga ao supabase-js para deixar valer os valores padrão da tabela:
await supabase.from('tasks').insert(rows, { defaultToNull: false })
Essa opção acrescenta o cabeçalho Prefer: missing=default que o PostgREST lê.
Uma única linha passada como objeto, sem o array em volta, não faz isso: uma chave
que falta nela falta na inserção, e o valor padrão se aplica.
Por que um upsert falha com isso se a linha já existe?
Porque o Postgres confere se a linha nova tem colunas obrigatórias vazias antes de procurar a linha existente que atualizaria. Um upsert é um formulário marcado "arquive, ou, se já houver uma ficha dessa pessoa, altere". O Postgres confere os asteriscos primeiro. Um formulário que traz só uma foto de perfil nova volta, mesmo com a ficha que ele devia alterar parada na gaveta.
A ordem está escrita
no código que executa uma inserção:
"Check the constraints of the tuple." vem antes de "Perform a speculative
insertion.", que é o passo que encontra o conflito ao qual uma cláusula
on conflict responde. Tuple é a palavra do Postgres para linha. Então este
upsert, feito para mudar uma coluna de um perfil que existe, falha em qualquer
outra coluna que a tabela exija:
await supabase.from('profiles').upsert({ id: user.id, avatar_url: url })
Para mudar algumas colunas de uma linha que já está lá, use um update. Ele cai sobre a linha gravada, que tem todas as colunas obrigatórias preenchidas, e muda só o que você envia:
await supabase.from('profiles').update({ avatar_url: url }).eq('id', user.id)
Deixe o upsert para linhas que talvez ainda não existam, e mande junto todas as colunas obrigatórias. O artigo sobre chaves duplicadas cobre o resto do que um upsert precisa: em qual coluna ele compara e quais regras de leitura ele aciona a partir do navegador.
Você deve remover a regra NOT NULL?
Só se a coluna nunca devia ter sido obrigatória. Um telefone que seu formulário trata como opcional é esse caso, e ali remover a regra é a correção certa:
alter table public.profiles alter column phone drop not null;
Para qualquer outra coluna, mantenha a regra e preencha o campo. Sem ela, cada
linha salva depois pode ficar sem o valor que a regra garantia, e seu app encontra
a lacuna em outro lugar: uma página que lê task.category.name para com
Cannot read properties of null, ou uma contagem de tarefas por categoria perde
em silêncio as que não têm nenhuma.
A coluna em que remover sai mais caro é user_id. Uma linha salva com dono vazio
não aparece para ninguém. Uma regra de leitura escrita como auth.uid() = user_id
nunca bate com ela, porque em SQL um valor vazio não é igual a nada, nem a outro
valor vazio. A tarefa fica salva, e daí em diante ninguém a vê, nem a pessoa que a
criou.
A correção para user_id é um valor padrão que preenche quem estiver logado:
alter table public.tasks
alter column user_id set default auth.uid();
O Postgres avalia um valor padrão "whenever the default value is inserted (not
when the table is created)", nas
palavras dele, então
cada linha nova recebe o id de quem está salvando. Sem ninguém logado,
auth.uid() retorna null, como avisa
o guia do Supabase sobre row level security,
e o salvamento continua falhando. É a regra fazendo o trabalho dela. O valor
padrão preenche o campo só quando seu app o deixa de fora, então o que impede
alguém de mandar o id de outra pessoa continua sendo a sua regra de inserção.
Essa regra de inserção também muda a mensagem que você vê. O Postgres roda as
verificações de Row Level Security de uma tabela numa linha nova antes das
verificações not-null:
o mesmo código de inserção
põe "Check any RLS WITH CHECK policies." antes de "Check the constraints of the
tuple.". Quando a sua regra de inserção compara user_id com auth.uid(), um
user_id vazio falha primeiro na regra e volta como
new row violates row-level security policy.
Um 23502 em user_id vindo do seu app significa uma de três coisas: a regra de
inserção nunca olha user_id, as regras estão desligadas nessa tabela, ou a
requisição veio de código de servidor que as pula.
É seguro mostrar esse erro aos seus usuários?
Não. Mostre uma frase sua e guarde o erro cru nos seus logs, porque a linha de detalhe, quando o Postgres a inclui, é uma cópia da linha inteira.
A linha de detalhe de uma chave duplicada repete um valor. Esta repete o
formulário.
A função que a escreve
mostra a linha como Failing row contains (…), com cada coluna que quem chamou
pode ler. Num update, é a linha como teria sido gravada, incluindo as colunas que
ninguém tocou. Um formulário que o Postgres devolve pode vir com uma fotocópia de
si mesmo grampeada na recusa, e a cópia mostra todos os campos.
O Postgres decide quem recebe a fotocópia, e o código dele confere primeiro:
"Check if RLS is enabled and should be active for the relation; if so, then don't
return anything." Uma requisição feita com a sua chave pública a uma tabela com
Row Level Security ligada recebe "details": null, que é o que o console no topo
desta página mostrou.
Dois chamadores recebem a cópia completa.
Um navegador, quando as regras da tabela estão desligadas. Se o console mostra
Failing row contains para uma requisição que seu app fez com a chave pública, a
Row Level Security está desligada nessa tabela. É a mesma leitura que
o artigo sobre chaves duplicadas
faz da própria linha de detalhe, e
ligar as regras em todas as tabelas
cobre o interruptor e a primeira regra. Uma tabela com as regras desligadas
entrega as linhas a um estranho tão facilmente quanto a você. Nossa
varredura gratuita lê seu app no ar com a chave pública que
qualquer visitante recebe e lista as tabelas que devolvem linhas. Não precisa de
conta.
Código de servidor que guarda a chave secreta. O guia do Supabase diz que a
chave secreta "authorizes access through the service_role Postgres role, which
has the bypassrls attribute", então as regras nunca se aplicam e a linha de
detalhe vem sempre preenchida. Uma Edge Function que atualiza uma linha e responde
com o erro que recebeu, return new Response(JSON.stringify(error)), manda ao
navegador todas as colunas da linha que estava mudando, seja de quem for essa
linha.
No código de servidor, guarde o erro e responda com suas próprias palavras:
if (error) {
console.error(error)
return new Response(JSON.stringify({ message: 'Não foi possível salvar.' }), {
status: 400,
})
}
No app, capture o código e diga o que a pessoa pode fazer:
const { error } = await supabase.from('tasks').insert(task)
if (error?.code === '23502') {
setMessage('Escolha uma categoria antes de salvar.')
} else if (error) {
setMessage('Algo deu errado. Tente de novo.')
}
Levar uma chave secreta para uma Edge Function cobre a parte da função, se a chave secreta ainda estiver no seu app.
Antes de mudar as regras de uma coluna
Guarde uma cópia do banco de antes de acrescentar um valor padrão, remover uma regra ou preencher um valor em todas as linhas, e fique sabendo quando uma tabela começar a responder a estranhos.
Reeve Care guarda uma cópia do seu banco no Supabase:
- uma cópia criptografada num cronograma, no armazenamento próprio da Reeve na UE, fora da sua conta do Supabase
- cada linha como estava antes de um update na tabela inteira reescrevê-la
- cada cópia verificada antes de valer, contando as linhas de cada tabela
- seus arquivos enviados também, assim que você conecta uma credencial do Storage
- uma restauração com um clique, com uma cópia de segurança do estado atual feita antes
- tudo o que o Monitor faz
Reeve Monitor continua conferindo de fora:
- se seu app está no ar, conferido a cada minuto
- a verificação de segurança completa a cada hora, em até três apps
- um e-mail no dia em que uma nova varredura der ao seu app uma nota pior que a anterior, para que uma tabela cujas regras foram desligadas chegue até você sem você ter que ir olhar
- um relatório mensal, em linguagem simples, do que ele viu
O que fazer agora
O que fazer
- Leia a coluna e a tabela na mensagem. É o campo que chegou vazio.
- Confira se seu app deixa esse campo de fora, manda como
nullou espera um valor padrão que a coluna não tem. - Para mudar algumas colunas de uma linha que existe, use
.update()com um filtro. Um upsert tem que levar todas as colunas obrigatórias. - Numa inserção em lote, mande todas as colunas em todas as linhas, ou passe
{ defaultToNull: false }para os valores padrão da tabela valerem. - Mantenha a regra em
user_ide dê à coluna o valor padrãoauth.uid(). - No código de servidor, registre o erro cru no log e responda ao navegador com uma frase sua.
Comece pela coluna da mensagem e depois pela linha do seu código que monta a linha. Se a correção muda a própria tabela, um valor padrão novo ou um update em todas as linhas, guarde uma cópia do banco de antes de rodar.
Perguntas frequentes
O que significa null value in column violates not-null constraint?
Significa que seu app tentou salvar uma linha sem valor numa coluna que sempre precisa ter um, e o Postgres recusou a linha inteira. Nada foi gravado. A mensagem nomeia a coluna e a tabela. O código é 23502, e de um app Supabase ele chega como HTTP 400.
Por que a coluna é null se ela tem um valor padrão?
Porque a requisição nomeou a coluna e a mandou vazia. O Postgres aplica um valor padrão só quando a coluna é deixada de fora. O supabase-js faz exatamente isso numa inserção em lote: uma coluna que uma linha tem e outra não entra como null na que não tem, a menos que você passe defaultToNull: false.
Por que meu upsert falha com um erro not-null se a linha existe?
O Postgres confere se a linha nova tem colunas obrigatórias vazias antes de procurar a linha existente que atualizaria. Um upsert que leva só as colunas que você quer mudar falha em qualquer outra coluna obrigatória. Para uma linha que já existe, use update com um filtro.
Devo remover a restrição NOT NULL?
Só se a coluna nunca devia ter sido obrigatória, como um telefone que seu formulário trata como opcional. Do contrário o erro para e linhas começam a chegar sem o valor, que seu app encontra em outro lugar. Uma linha com user_id vazio não aparece para ninguém. Descubra de onde vem o null, ou dê à coluna um valor padrão como auth.uid().
É seguro mostrar esse erro aos usuários?
Não. Quando o Postgres inclui a linha de detalhe, ela é uma cópia da linha inteira. Uma requisição do navegador a uma tabela com Row Level Security ligada não recebe nenhuma, mas código de servidor com a chave secreta recebe sempre. Capture o código 23502, mostre uma frase escrita por você e guarde o erro cru nos seus logs.