Bases de la sécurité
Supabase "multiple (or no) rows returned" veut dire zéro ligne
Supabase renvoie PGRST116, "JSON object requested, multiple (or no) rows returned", quand la requête n'a rien trouvé. Une ligne cachée semble absente.

En bref
- "JSON object requested, multiple (or no) rows returned" est l'erreur PGRST116, et elle veut dire que votre requête est revenue avec un nombre de lignes autre que un. Presque toujours, ce nombre est zéro.
- Avec Row Level Security activée, une ligne que votre visiteur n'a pas le droit de lire revient comme aucune ligne du tout. Un enregistrement que vous voyez dans le Table Editor de Supabase peut donc manquer dans votre app alors que les données vont très bien.
- Utilisez maybeSingle() là où ne rien trouver est une réponse légitime, et single() là où ça ne l'est pas. Lancez d'abord une requête dans le SQL Editor, pour savoir lequel des deux cas vous avez.
Votre app annonce à quelqu'un qu'un enregistrement est introuvable. Vous ouvrez le Table Editor de Supabase et l'enregistrement est là, dans la table, avec le bon id.
Voici ce qui est revenu de la base :
{
"code": "PGRST116",
"message": "JSON object requested, multiple (or no) rows returned",
"details": "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row"
}
Voici la partie que guide après guide rate : zéro ligne est une réponse que vos
propres règles peuvent produire, et elle se lit exactement comme des données
disparues. Cherchez ce message et on vous dira de remplacer .single() par
.maybeSingle(). Cela arrête bien l'erreur. Cela ne vous dit rien sur le fait
que la ligne manquait ou que votre visiteur n'avait jamais le droit de la voir,
et ces deux cas se réparent différemment.
Que veut dire "JSON object requested, multiple (or no) rows returned" ?
Votre requête a demandé exactement une ligne à la base et le résultat en contenait un autre nombre. Presque toujours, ce nombre est zéro.
Imaginez une salle de lecture où vous remplissez un bulletin et où quelqu'un derrière le comptoir va chercher le livre. Votre bulletin dit : apportez-moi l'unique livre portant ce numéro. Le comptoir revient et vous dit qu'il n'y a rien qui corresponde au bulletin. C'est la seule réponse que donne le comptoir, et c'est la même que le livre n'ait jamais figuré au catalogue ou qu'il soit sur une étagère pour laquelle vous n'avez pas d'autorisation. Personne ne joue avec vous. Le système de bulletins a été bâti pour que ce à quoi vous n'avez pas droit soit tout bonnement absent de votre vue du catalogue.
C'est votre app qui tient le bulletin, et le nombre avec lequel le comptoir est
revenu est écrit dans la ligne details de la réponse.
Deux messages différents, et le même code d'erreur
Le code est stable et la phrase ne l'est pas, alors fiez-vous à PGRST116.
Deux formulations circulent pour cette unique erreur, et celle que vous voyez
dépend de la version de PostgREST que fait tourner votre projet. PostgREST est le
serveur qui transforme vos tables Supabase en l'API HTTP à laquelle votre app
parle. Sa documentation pour la version 12 donne le message
JSON object requested, multiple (or no) rows returned, avec une ligne details
qui dit
Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row. Dans
les versions 13 et 14, la même situation est documentée comme
Cannot coerce the result to a single JSON object, avec details qui dit
The result contains 0 rows.
L'ancienne phrase a une seconde vie au-delà des vieux projets. Quand vous
appelez .maybeSingle() et que plus d'une ligne correspond, le client JavaScript
de Supabase écrit l'erreur lui-même au lieu d'en transmettre une venue du
serveur, et le message qu'il écrit est l'ancienne formulation. La première phrase
arrive donc encore dans des apps dont la base aurait envoyé la seconde.
Ce qui ne bouge pas, c'est le code. La table d'erreurs de PostgREST liste
PGRST116 en 406 dans les trois versions, avec la même description : plus d'une
ligne, ou aucune, alors qu'une seule était demandée. Lisez le champ code et
ignorez la prose.
Pourquoi .single() produit ça
Parce que .single() est une instruction sur la forme de la réponse et non un
filtre sur les données.
Quand vous écrivez .single(), le client Supabase ajoute un en-tête à la
requête : Accept: application/vnd.pgrst.object+json. Cet en-tête demande à la
base d'envoyer la ligne seule, comme un enregistrement. C'est lui qui permet à
votre code d'écrire data.name là où il faudrait sinon écrire data[0].name.
La base s'y tient strictement. Une ligne repart comme un enregistrement. Tout
autre nombre de lignes ne peut pas être envoyé sous cette forme, donc la réponse
est un 406 et un objet d'erreur. Retirez le .single() et la requête identique
renvoie [] avec un 200 et aucune erreur nulle part.
Rien n'a donc eu besoin de casser pour produire ça. Un résultat vide est devenu une erreur parce que votre requête disait que la réponse devait contenir exactement un enregistrement.
Cause 1 : la ligne est là et vos règles disent non
Row Level Security ne refuse pas votre requête. Elle en retire des lignes, et une ligne retirée n'a jamais été dans le résultat qu'on compte.
Row Level Security est l'ensemble de règles posées sur chaque table Supabase qui
décide, ligne par ligne, quelles lignes un visiteur donné a le droit de lire.
Quand aucune règle n'autorise votre visiteur à lire la ligne que votre requête a
nommée, cette ligne est retirée avant que le résultat soit mesuré. Le compte est
zéro. Votre .single() en voulait une. Vous obtenez PGRST116, et aucune partie
de la réponse ne mentionne qu'une règle est intervenue.
C'est de nouveau la salle de lecture, et c'est pour ça que le comptoir dit deux fois la même chose. Votre autorisation s'applique avant même que l'étagère soit fouillée, donc un livre que vous ne pouvez pas emprunter et un livre qui n'existe pas produisent un bulletin identique qui revient.
C'est aussi pour ça que le Table Editor vous induit en erreur. Le SQL Editor et le Table Editor de votre console Supabase tournent avec des privilèges auxquels vos policies ne s'appliquent pas, donc ils vous montrent chaque ligne. La requête de votre app porte votre publishable key et la session de votre visiteur, et les policies s'y appliquent. Deux vues d'une même table, et une seule est celle que vos utilisateurs obtiennent.
Cette différence se lit de l'extérieur, et c'est ainsi que nous pouvons la compter. Notre scanner lit les apps en ligne comme le ferait un inconnu. Derrière 8 435 des apps qu'il a notées, il y a un projet Supabase. Sur 3 680 d'entre elles, la question « quelqu'un sans compte peut-il lire cette table » a obtenu une réponse exploitable, et 1 584 ont répondu non : un inconnu qui demandait les lignes n'a rien reçu. Ce rien est le même rien que reçoit votre propre app quand aucune policy ne lui correspond.
Distinguer une ligne cachée d'une ligne absente
Lancez la même recherche deux fois, une fois là où les policies s'appliquent et une fois là où elles ne s'appliquent pas. Une requête tranche.
- Ouvrez votre console Supabase, allez dans le SQL Editor et sélectionnez la
ligne par la même valeur que celle utilisée par votre app :
select * from your_table where id = 'the-id-your-app-sent';. Une requête lancée depuis le SQL Editor passe par le rôlepostgres, auquel vos policies ne s'appliquent pas. - Si elle renvoie la ligne, la ligne existe et quelque chose entre votre app et elle l'a retirée. Ce quelque chose est une policy, et vous êtes dans la cause 1. Lisez les policies de cette table.
- Si elle ne renvoie rien non plus, la ligne est vraiment absente et vous êtes dans la cause 3 plus bas. Vos règles ne sont pour rien.
Faites-le avant de changer une ligne de code. Les deux causes produisent le même objet d'erreur, et les deux réparations n'ont rien en commun.
Tant que vous regardez les policies de cette table, il vaut la peine de savoir ce qu'elles disent aujourd'hui à quelqu'un sans aucun compte. Notre scan gratuit lit votre app en ligne et vous dit lesquelles de vos tables répondent à un inconnu. Il prend une vingtaine de secondes et ne demande pas de compte : scanner votre app.
Cause 2 : plusieurs lignes sont revenues
.single() a été utilisé sur une recherche qui peut correspondre à plus d'une
ligne.
Une recherche par clé primaire correspond à une ligne ou à aucune, par définition. Une recherche par e-mail, par slug, par nom, par statut, par clé étrangère : chacune peut en toucher deux. L'histoire habituelle est qu'une colonne portait une valeur par ligne dans vos données de test et a cessé de le faire une fois de vraies personnes arrivées.
La réponse vous dit dans quelle cause vous êtes. Les deux formulations de la
ligne details indiquent combien de lignes le résultat contenait. Zéro vous
renvoie à la cause 1 ou à la cause 3. Un nombre supérieur à zéro désigne
celle-ci, et c'est le compte des lignes qui ont correspondu.
La réparation n'est pas .maybeSingle(). Il accepte zéro ligne et signale
toujours une erreur sur deux, donc il ne change rien ici. Soit vous restreignez
la requête à une ligne avec la colonne qui est vraiment unique, soit vous
acceptez qu'il y en ait plusieurs et vous lisez une liste.
Cause 3 : la ligne a vraiment disparu
Parfois la ligne est absente, et la requête de la section précédente est ce qui le confirme.
Les raisons ordinaires, dans l'ordre où elles apparaissent dans une app vibe-codée. La valeur sur laquelle votre requête filtrait était vide, parce que l'id n'était pas chargé au moment où la requête est partie, donc rien ne correspondait. La ligne a été supprimée, par vous ou par un prompt qui a réécrit plus que prévu. Ou la requête nomme une table reconstruite sous un autre nom, et les anciennes lignes sont parties avec l'ancienne table.
Rien de tout cela n'est un problème de sécurité, et aucun ne se règle en changeant une policy. Si le SQL Editor ne trouve rien, arrêtez de lire sur Row Level Security et allez voir ce que votre app transmet.
single ou maybeSingle : lequel votre requête veut
.maybeSingle() là où ne rien trouver est une réponse légitime. .single() là
où ça ne l'est pas.
.single() traite tout nombre de lignes autre que un comme une erreur. C'est le
bon comportement pour une recherche par clé primaire, où zéro ligne veut dire que
l'enregistrement dont parle votre code n'existe pas et que continuer serait pire
que s'arrêter.
.maybeSingle() accepte zéro ligne : il vous rend data à null, sans erreur et
avec un 200. Plus d'une ligne reste une erreur, avec le même code PGRST116. Il
convient donc à une ligne de profil qui n'a peut-être pas encore été créée, ou à
un enregistrement facultatif pour lequel votre page affiche un état vide.
Une chose à surveiller. Remplacer .single() par .maybeSingle() change le
comportement de votre app et ne change rien à ce que la base a envoyé. Si une
policy cachait la ligne, vous avez transformé une erreur visible en une page qui
s'affiche comme si l'enregistrement n'existait pas, pour la seule personne qui
devait le voir. L'erreur vous disait quelque chose. Faites l'échange après le
test ci-dessus, une fois que vous savez que zéro est la réponse honnête.
Garder un œil sur vos règles après les avoir changées
Une policy que vous élargissez pour faire taire cette erreur est un changement que personne ne voit depuis l'intérieur de votre app. Votre propre session continue de marcher, l'erreur a disparu, et savoir si cette table répond désormais à un inconnu est une question que quelqu'un doit aller poser.
Reeve Monitor la pose pour vous :
- les neuf contrôles toutes les heures, sur trois apps au plus
- un e-mail le jour où un nouveau scan note votre app plus bas que le précédent
- si l'app répond, toutes les 60 secondes
- un rapport mensuel de ce qu'il a vu
Une règle assouplie assez pour faire taire une erreur peut laisser un inconnu modifier des lignes autant que les lire. Reeve Care garde une copie de votre base Supabase là où aucun changement de vos règles ne peut l'atteindre.
- une copie chiffrée chaque jour, conservée hors de votre compte Supabase
- chaque copie vérifiée avant de compter, en comptant les lignes de chaque table
- une restauration en un clic qui sauvegarde ce qui est là avant de le remplacer
- vos fichiers téléversés aussi, dès que vous connectez un accès Storage
- tout ce que fait Monitor
À faire aujourd'hui
Que faire
- Fiez-vous au champ
code.PGRST116arrive sous deux phrases différentes, et celle que vous obtenez dépend de la version de PostgREST que fait tourner votre projet. - Lisez le nombre de lignes dans la ligne
details. Zéro veut dire résultat vide. Au-dessus de zéro, votre recherche a touché plusieurs lignes et a besoin d'une colonne unique. - Sur un résultat vide, lancez la même recherche dans le SQL Editor avant de changer du code. Si la ligne revient là et pas dans votre app, une policy l'a retirée.
- Passez à
.maybeSingle()une fois que vous savez que zéro est la réponse honnête. Sur une ligne qu'une policy cache, il remplace une erreur par une page vide, sans prévenir. - Après avoir changé une policy pour faire taire ça, vérifiez ce que cette table renvoie maintenant à quelqu'un sans compte.
Commencez par une requête dans le SQL Editor, parce que c'est elle qui décide quelle moitié de cet article vous concerne. S'il s'avère qu'une policy était la réponse, activer Row Level Security sur chaque table montre comment écrire une règle qui laisse passer la bonne personne, et "new row violates row-level security policy" est le message que vous obtenez quand une policy refuse une écriture que vous voyez de vos propres yeux.
FAQ
Que veut dire PGRST116 ?
Cela veut dire que votre requête a demandé exactement une ligne à la base et que le résultat en contenait un autre nombre. PostgREST, la partie de Supabase qui répond à votre app en HTTP, le renvoie en HTTP 406 avec le code PGRST116. Le nombre est dans la ligne details de la réponse, et presque toujours il vaut zéro.
Pourquoi ma requête ne renvoie aucune ligne alors que je vois la ligne dans Supabase ?
Parce que le SQL Editor de votre console tourne avec des privilèges auxquels vos policies Row Level Security ne s'appliquent pas, et pas votre app. Si aucune policy n'autorise votre visiteur à lire cette ligne, elle est retirée de son résultat avant que le résultat soit compté, et ce qui revient est un compte de zéro. La ligne est là. Elle n'a jamais été dans la réponse.
Quelle est la différence entre single et maybeSingle ?
single() traite tout nombre de lignes autre que un comme une erreur. maybeSingle() accepte zéro ligne et vous rend data à null, sans erreur, et signale toujours une erreur quand plus d'une ligne correspond. maybeSingle() convient donc là où ne rien trouver est une réponse légitime, comme un profil qui n'existe pas encore, et single() convient à une recherche sur une colonne qui ne peut correspondre qu'à une seule ligne.
Est-ce Row Level Security qui provoque cette erreur ?
C'est la cause la plus fréquente dans une app construite avec Lovable, Bolt, Replit ou v0. Row Level Security filtre les lignes hors du résultat au lieu de refuser la requête, donc une ligne que personne n'a le droit de lire est impossible à distinguer d'une ligne qui n'existe pas. Les deux donnent un compte de zéro, et une requête qui exige exactement une ligne échoue sur les deux.
Pourquoi un 406 ?
406 Not Acceptable est le statut d'une requête à laquelle le serveur ne peut pas répondre dans la forme demandée. Votre requête portait un en-tête Accept disant que la réponse devait être un enregistrement unique et non une liste, et sans ligne à mettre dedans il n'y a aucune réponse acceptable à envoyer. Retirez cet en-tête et la même requête renvoie une liste vide avec un 200.