Pular para o conteúdo

Noções de segurança

Supabase "multiple (or no) rows returned" quase sempre é nenhuma

O Supabase devolve PGRST116, "JSON object requested, multiple (or no) rows returned", quando a consulta não achou nada. Linha escondida parece ausente.

Vlad Tkachenko11 min de leitura
Uma ficha de pedido e uma bandeja de devolução vazia sobre um balcão, e atrás uma estante com uma linha destacada em teal por trás de uma barra âmbar.

Em resumo

  • "JSON object requested, multiple (or no) rows returned" é o erro PGRST116, e quer dizer que sua consulta voltou com um número de linhas diferente de um. Quase sempre esse número é zero.
  • Com Row Level Security ligada, uma linha que seu visitante não pode ler volta como nenhuma linha. Então um registro que você vê no Table Editor do Supabase pode faltar no seu app enquanto os dados estão perfeitamente bem.
  • Use maybeSingle() onde não achar nada for uma resposta legítima e single() onde não for. Rode antes uma consulta no SQL Editor, para saber qual dos dois casos você tem.

Seu app diz a alguém que um registro não foi encontrado. Você abre o Table Editor do Supabase e o registro está ali, na tabela, com o id certo.

Isto foi o que voltou do banco:

{
  "code": "PGRST116",
  "message": "JSON object requested, multiple (or no) rows returned",
  "details": "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row"
}

E esta é a parte que guia após guia erra: zero linhas é uma resposta que suas próprias regras conseguem produzir, e ela se lê exatamente como dados que sumiram. Procure essa mensagem e vão te dizer para trocar .single() por .maybeSingle(). Isso de fato para o erro. Não te diz nada sobre a linha estar faltando ou sobre seu visitante nunca ter tido permissão de vê-la, e esses dois casos se consertam de formas diferentes.

O que significa "JSON object requested, multiple (or no) rows returned"?

Sua consulta pediu ao banco exatamente uma linha e o resultado tinha outro número. Quase sempre esse número é zero.

Pense numa sala de leitura onde você preenche uma ficha e alguém atrás do balcão vai buscar o livro. Sua ficha diz: traga o único livro com este número. O balcão volta e diz que não há nada correspondente à ficha. Essa é a única resposta que o balcão dá, e é a mesma tanto se o livro nunca esteve no catálogo quanto se ele está numa estante para a qual você não tem autorização. Ninguém está te enrolando. O sistema de fichas foi construído para que aquilo que você não tem autorização de ver simplesmente não apareça na sua visão do catálogo.

Seu app é quem segura a ficha, e o número com que o balcão voltou está escrito na linha details da resposta.

Duas mensagens diferentes, e o mesmo código de erro

O código é estável e a frase não é, então se guie pelo PGRST116.

Há duas redações em circulação para esse único erro, e qual você vê depende de qual versão do PostgREST seu projeto roda. O PostgREST é o servidor que transforma suas tabelas do Supabase na API HTTP com que seu app conversa. A documentação da versão 12 dá a mensagem JSON object requested, multiple (or no) rows returned, com uma linha details dizendo Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row. Nas versões 13 e 14 a mesma situação está documentada como Cannot coerce the result to a single JSON object, com details dizendo The result contains 0 rows.

A frase mais antiga tem uma segunda vida além dos projetos velhos. Quando você chama .maybeSingle() e mais de uma linha bate, o cliente JavaScript do Supabase escreve o erro ele mesmo em vez de repassar um do servidor, e a mensagem que ele escreve é a redação antiga. Então a primeira frase continua chegando em apps cujo banco teria mandado a segunda.

O que não se move é o código. A tabela de erros do PostgREST lista PGRST116 em 406 nas três versões, com a mesma descrição: mais de uma linha, ou nenhuma, quando exatamente uma foi pedida. Leia o campo code e ignore o texto.

Por que .single() produz isso

Porque .single() é uma instrução sobre a forma da resposta e não um filtro sobre os dados.

Quando você escreve .single(), o cliente do Supabase acrescenta um cabeçalho ao pedido: Accept: application/vnd.pgrst.object+json. Esse cabeçalho pede que o banco mande a linha sozinha, como um registro. É ele que deixa seu código escrever data.name onde de outra forma teria de escrever data[0].name.

O banco cumpre isso à risca. Uma linha volta como um registro. Qualquer outro número de linhas não pode ser mandado nessa forma, então a resposta é um 406 e um objeto de erro. Tire o .single() e a consulta idêntica devolve [] com um 200 e nenhum erro em lugar nenhum.

Então nada precisou quebrar para produzir isso. Um resultado vazio virou um erro porque sua consulta disse que a resposta tinha de conter exatamente um registro.

Causa 1: a linha está lá e suas regras dizem não

Row Level Security não recusa sua consulta. Ela tira linhas dela, e uma linha tirada nunca esteve no resultado que é contado.

Row Level Security é o conjunto de regras em cada tabela do Supabase que decide, linha a linha, quais linhas um dado visitante pode ler. Quando nenhuma regra permite que seu visitante leia a linha que sua consulta nomeou, essa linha é retirada antes de o resultado ser medido. A contagem é zero. Seu .single() queria uma. Você recebe PGRST116, e nenhuma parte da resposta menciona que houve uma regra no meio.

É a sala de leitura de novo, e é por isso que o balcão diz a mesma coisa duas vezes. Sua autorização é aplicada antes mesmo de a estante ser vasculhada, então um livro que você não pode levar e um livro que não existe produzem uma ficha idêntica de volta.

Uma linha que suas regras escondem e uma linha que não está deixam o resultado vazio do mesmo jeito. A resposta conta linhas, então as duas chegam como o mesmo erro.

É também por isso que o Table Editor te engana. O SQL Editor e o Table Editor do seu painel do Supabase rodam com privilégios aos quais suas policies não se aplicam, então mostram todas as linhas. O pedido do seu app leva sua publishable key e a sessão do seu visitante, e nele as policies se aplicam. Duas visões de uma mesma tabela, e só uma delas é a que seus usuários recebem.

Essa diferença dá para ler de fora, e é assim que conseguimos contá-la. Nosso scanner lê apps no ar como um estranho faria. Atrás de 8.435 dos apps que ele avaliou há um projeto Supabase. Em 3.680 deles a pergunta "alguém sem conta consegue ler esta tabela?" teve uma resposta utilizável, e 1.584 responderam que não: um estranho que pediu as linhas não recebeu nada. Esse nada é o mesmo nada que o seu próprio app recebe quando nenhuma policy se aplica a ele.

Como distinguir uma linha escondida de uma que falta

Rode a mesma busca duas vezes, uma onde as policies se aplicam e outra onde não se aplicam. Uma consulta resolve.

  1. Abra seu painel do Supabase, vá no SQL Editor e pegue a linha pelo mesmo valor que seu app usou: select * from your_table where id = 'the-id-your-app-sent';. Uma consulta a partir do SQL Editor roda pelo papel postgres, ao qual suas policies não se aplicam.
  2. Se ela devolver a linha, a linha existe e alguma coisa entre seu app e a linha a retirou. Essa coisa é uma policy, e você está na causa 1. Leia as policies daquela tabela.
  3. Se ela também não devolver nada, a linha falta de verdade e você está na causa 3 mais abaixo. Suas regras não têm nada a ver.

Faça isso antes de mudar uma linha de código. As duas causas produzem o mesmo objeto de erro, e os dois consertos não têm nada em comum.

Já que você está olhando as policies daquela tabela, vale saber o que elas dizem hoje para alguém sem conta nenhuma. Nosso scan gratuito lê seu app no ar e diz quais das suas tabelas respondem a um estranho. Leva cerca de 20 segundos e não precisa de conta: escanear seu app.

Causa 2: voltaram várias linhas

.single() foi usado numa busca que pode bater com mais de uma linha.

Uma busca por chave primária bate com uma linha ou com nenhuma, por definição. Uma busca por email, por slug, por nome, por status, por chave estrangeira: qualquer uma delas pode tocar duas. A história de sempre é que a coluna tinha um valor por linha nos seus dados de teste e deixou de ter quando gente de verdade chegou.

A resposta te diz em qual causa você está. As duas redações da linha details dizem quantas linhas o resultado continha. Zero te manda para a causa 1 ou para a causa 3. Um número acima de zero aponta esta aqui, e é a contagem das linhas que bateram.

O conserto não é .maybeSingle(). Ele aceita zero linhas e continua dando erro com duas, então aqui não muda nada. Ou você estreita a consulta para uma linha com a coluna que é de fato única, ou aceita que há várias e lê uma lista.

Causa 3: a linha sumiu mesmo

Às vezes a linha falta, e a consulta da seção anterior é o que confirma isso.

Os motivos comuns, na ordem em que aparecem num app feito no vibe coding. O valor pelo qual sua consulta filtrava estava vazio, porque o id não tinha carregado quando a consulta saiu, então nada bateu. A linha foi apagada, por você ou por um prompt que reescreveu mais do que você esperava. Ou a consulta nomeia uma tabela que foi refeita com outro nome e as linhas antigas foram junto com a tabela antiga.

Nada disso é um problema de segurança, e nenhum se resolve mudando uma policy. Se o SQL Editor não achar nada, pare de ler sobre Row Level Security e vá ver o que seu app está passando.

single ou maybeSingle: qual a sua consulta quer

.maybeSingle() onde não achar nada é uma resposta legítima. .single() onde não é.

.single() trata qualquer número de linhas diferente de um como erro. É o comportamento certo para uma busca por chave primária, onde zero linhas quer dizer que o registro de que seu código trata não existe e seguir em frente seria pior do que parar.

.maybeSingle() aceita zero linhas: devolve data como null, sem erro e com um 200. Mais de uma linha continua sendo erro, com o mesmo código PGRST116. Então ele serve para uma linha de perfil que pode ainda não ter sido criada, ou para um registro opcional para o qual sua página desenha um estado vazio.

A única diferença está no que cada um faz com um resultado vazio. Os dois recusam duas linhas, e nenhum deles muda o que o banco realmente devolveu.

Uma coisa para tomar cuidado. Trocar .single() por .maybeSingle() muda como seu app se comporta e não muda nada do que o banco mandou. Se uma policy estava escondendo a linha, você transformou um erro visível numa página que aparece como se o registro não existisse, justo para a única pessoa que deveria vê-lo. O erro estava te dizendo alguma coisa. Faça a troca depois do teste acima, quando você souber que zero é a resposta honesta.

Ficar de olho nas suas regras depois de mudá-las

Uma policy que você abre para se livrar desse erro é uma mudança que ninguém vê de dentro do seu app. Sua própria sessão continua funcionando, o erro sumiu, e se aquela tabela agora responde a um estranho é uma pergunta que alguém precisa ir fazer.

O Reeve Monitor faz ela por você:

  • as nove verificações a cada hora, em até três apps
  • um email no dia em que um novo scan avaliar seu app pior que o anterior
  • se o app responde, a cada 60 segundos
  • um relatório mensal do que ele viu

Uma regra afrouxada o bastante para se livrar de um erro pode deixar um estranho mudar linhas além de lê-las. O Reeve Care guarda uma cópia do seu banco de dados do Supabase onde nenhuma mudança nas suas regras alcança.

  • uma cópia criptografada todo dia, guardada fora da sua conta do Supabase
  • cada cópia verificada antes de contar, contando as linhas de cada tabela
  • uma restauração em um clique que salva o que está lá antes de substituir
  • seus arquivos enviados também, assim que você conectar um acesso de Storage
  • tudo o que o Monitor faz

O que fazer hoje

O que fazer

  • Se guie pelo campo code. PGRST116 chega sob duas frases diferentes, e qual delas você recebe depende da versão do PostgREST que seu projeto roda.
  • Leia a contagem de linhas na linha details. Zero quer dizer resultado vazio. Acima de zero, sua busca tocou várias linhas e precisa de uma coluna única.
  • Num resultado vazio, rode a mesma busca no SQL Editor antes de mudar código. Se a linha voltar lá e não no seu app, uma policy a retirou.
  • Passe para .maybeSingle() quando souber que zero é a resposta honesta. Numa linha que uma policy esconde, ele troca um erro por uma página vazia, sem aviso.
  • Depois de mudar uma policy para se livrar disso, verifique o que aquela tabela devolve agora para alguém sem conta.

Comece por uma consulta no SQL Editor, porque é ela que decide qual metade deste artigo vale para você. Se a resposta for uma policy, ligar Row Level Security em todas as tabelas mostra como escrever uma regra que deixa a pessoa certa passar, e "new row violates row-level security policy" é a mensagem que você recebe quando uma policy recusa uma escrita que você vê com os próprios olhos.

Perguntas frequentes

O que significa PGRST116?

Significa que sua consulta pediu ao banco exatamente uma linha e o resultado tinha outro número. O PostgREST, a parte do Supabase que responde ao seu app por HTTP, devolve isso como HTTP 406 com o código PGRST116. O número está na linha details da resposta, e quase sempre é zero.

Por que minha consulta não devolve linha nenhuma se eu vejo a linha no Supabase?

Porque o SQL Editor do seu painel roda com privilégios aos quais suas policies de Row Level Security não se aplicam, e seu app não. Se nenhuma policy permite que seu visitante leia aquela linha, ela é retirada do resultado dele antes de o resultado ser contado, e o que volta é um zero. A linha está lá. Ela nunca esteve na resposta.

Qual é a diferença entre single e maybeSingle?

single() trata qualquer número de linhas diferente de um como erro. maybeSingle() aceita zero linhas e devolve data como null, sem erro, e continua dando erro quando mais de uma linha bate. Então maybeSingle() serve onde não achar nada é uma resposta legítima, como um perfil que ainda não existe, e single() serve para uma busca por uma coluna que só pode bater com uma linha.

Row Level Security causa esse erro?

É a causa mais comum num app feito com Lovable, Bolt, Replit ou v0. Row Level Security filtra linhas para fora do resultado em vez de recusar o pedido, então uma linha que ninguém pode ler é indistinguível de uma linha que não existe. As duas dão contagem zero, e uma consulta que exige exatamente uma linha falha nas duas.

Por que é um 406?

406 Not Acceptable é o status de um pedido que o servidor não consegue responder na forma pedida. Sua consulta levava um cabeçalho Accept dizendo que a resposta tinha de ser um registro único e não uma lista, e sem linha nenhuma para pôr dentro não há resposta aceitável para mandar. Tire esse cabeçalho e a mesma consulta devolve uma lista vazia com um 200.

Escrito por

Vlad Tkachenko

Fundador da Reeve

Passo meus dias olhando apps criados com Lovable, Bolt, v0, Cursor e Replit, e a curta lista de erros que aparecem neles sem parar.

Mais sobre o autor

Leia depois

Todos os artigos

Faça backup do seu banco Supabase antes da próxima mudança

O Reeve guarda uma cópia criptografada em horários programados e a restaura para você se algo quebrar.

Ver backups do Supabase

Verificação externa automatizada, não uma auditoria completa. A ausência de achados não é garantia de segurança.