Sicherheitsgrundlagen
Supabase "multiple (or no) rows returned" heißt meist: keine Zeilen
Supabase gibt PGRST116 zurück, "JSON object requested, multiple (or no) rows returned", wenn die Abfrage nichts fand. Versteckte Zeilen wirken fehlend.

Kurz gesagt
- "JSON object requested, multiple (or no) rows returned" ist der Fehler PGRST116, und er heißt: deine Abfrage kam mit einer anderen Zahl von Zeilen zurück als genau einer. Fast immer ist diese Zahl null.
- Mit eingeschalteter Row Level Security kommt eine Zeile, die dein Besucher nicht lesen darf, als gar keine Zeile zurück. Ein Datensatz, den du im Supabase Table Editor siehst, kann in deiner App fehlen, während mit den Daten alles in Ordnung ist.
- Nimm maybeSingle(), wo nichts zu finden eine zulässige Antwort ist, und single(), wo es das nicht ist. Setz vorher eine Abfrage im SQL Editor ab, damit du weißt, welcher der beiden Fälle vorliegt.
Jemand bekommt von deiner App gesagt, ein Datensatz sei nicht zu finden. Du öffnest den Supabase Table Editor, und der Datensatz liegt genau dort, in der Tabelle, mit der richtigen id.
Aus der Datenbank kam das hier zurück:
{
"code": "PGRST116",
"message": "JSON object requested, multiple (or no) rows returned",
"details": "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row"
}
Und hier ist der Teil, den eine Anleitung nach der anderen falsch macht: null
Zeilen ist eine Antwort, die deine eigenen Regeln erzeugen können, und sie liest
sich genau wie verschwundene Daten. Such diese Meldung, und man sagt dir, du
sollst .single() durch .maybeSingle() ersetzen. Das beendet den Fehler
tatsächlich. Es sagt dir nichts darüber, ob die Zeile fehlte oder ob dein
Besucher sie nie sehen durfte, und diese beiden Fälle werden unterschiedlich
repariert.
Was heißt "JSON object requested, multiple (or no) rows returned"?
Deine Abfrage hat die Datenbank nach genau einer Zeile gefragt, und das Ergebnis enthielt eine andere Zahl. Fast immer ist diese Zahl null.
Stell dir einen Lesesaal vor, in dem du einen Zettel ausfüllst und jemand hinter dem Tresen das Buch holt. Auf deinem Zettel steht: bring mir das eine Buch mit dieser Nummer. Der Tresen kommt zurück und sagt dir, zu diesem Zettel gebe es nichts. Das ist die einzige Antwort, die der Tresen gibt, und sie ist dieselbe, ob das Buch nie im Katalog stand oder ob es in einem Regal liegt, für das du keine Freigabe hast. Niemand hält dir etwas vor. Das Zettelsystem wurde so gebaut, dass alles, wofür du keine Freigabe hast, in deiner Sicht auf den Katalog schlicht nicht vorkommt.
Deine App ist diejenige, die den Zettel hält, und die Zahl, mit der der Tresen
zurückkam, steht in der details-Zeile der Antwort.
Zwei verschiedene Meldungen, und derselbe Fehlercode
Der Code steht fest, der Satz nicht, also geh nach PGRST116.
Für diesen einen Fehler sind zwei Formulierungen im Umlauf, und welche du siehst,
hängt davon ab, welche Version von PostgREST dein Projekt fährt. PostgREST ist
der Server, der deine Supabase-Tabellen in die HTTP-API verwandelt, mit der deine
App spricht. Seine Dokumentation zu Version 12 nennt die Meldung
JSON object requested, multiple (or no) rows returned, mit einer
details-Zeile, die
Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row
lautet. In den Versionen 13 und 14 ist dieselbe Lage als
Cannot coerce the result to a single JSON object dokumentiert, mit details als
The result contains 0 rows.
Der ältere Satz hat ein zweites Leben jenseits alter Projekte. Rufst du
.maybeSingle() auf und passt mehr als eine Zeile, schreibt der JavaScript-Client
von Supabase den Fehler selbst, statt einen vom Server weiterzureichen, und die
Meldung, die er schreibt, ist die ältere Formulierung. Der erste Satz kommt also
immer noch in Apps an, deren Datenbank den zweiten geschickt hätte.
Was sich nicht bewegt, ist der Code. PostgRESTs Fehlertabelle führt PGRST116 in
allen drei Versionen unter 406, mit derselben Beschreibung: mehr als eine Zeile
oder keine, wenn genau eine verlangt wurde. Lies das Feld code und ignorier den
Fließtext.
Warum .single() das überhaupt erzeugt
Weil .single() eine Anweisung über die Form der Antwort ist und kein Filter auf
den Daten.
Schreibst du .single(), fügt der Supabase-Client der Anfrage einen Header
hinzu: Accept: application/vnd.pgrst.object+json. Dieser Header bittet die
Datenbank, die Zeile für sich zu schicken, als Datensatz. Er ist es, der deinem
Code erlaubt, data.name zu schreiben, wo sonst data[0].name stehen müsste.
Die Datenbank hält sich strikt daran. Eine Zeile geht als Datensatz zurück. Jede
andere Zahl von Zeilen lässt sich in dieser Form gar nicht schicken, also ist die
Antwort ein 406 und ein Fehlerobjekt. Lass das .single() weg, und dieselbe
Abfrage gibt [] mit einem 200 zurück, ohne Fehler.
Es musste also nichts kaputtgehen, damit das hier entsteht. Ein leeres Ergebnis wurde zu einem Fehler, weil deine Abfrage sagte, die Antwort müsse genau einen Datensatz enthalten.
Ursache 1: die Zeile ist da, und deine Regeln sagen nein
Row Level Security lehnt deine Abfrage nicht ab. Sie nimmt Zeilen heraus, und eine herausgenommene Zeile war nie im Ergebnis, das gezählt wird.
Row Level Security ist der Satz an Regeln auf jeder Supabase-Tabelle, der Zeile
für Zeile entscheidet, welche Zeilen ein bestimmter Besucher lesen darf. Erlaubt
keine Regel deinem Besucher, die Zeile zu lesen, die deine Abfrage genannt hat,
wird diese Zeile entfernt, bevor das Ergebnis gemessen wird. Die Zahl ist null.
Dein .single() wollte eins. Du bekommst PGRST116, und kein Teil der Antwort
erwähnt, dass eine Regel beteiligt war.
Das ist wieder der Lesesaal, und darum sagt der Tresen zweimal dasselbe. Deine Freigabe wird angewendet, bevor das Regal überhaupt durchsucht wird, also erzeugen ein Buch, das du nicht ausleihen darfst, und ein Buch, das es nicht gibt, einen identischen Zettel, der zurückkommt.
Deshalb führt dich auch der Table Editor in die Irre. Der SQL Editor und der Table Editor in deiner Supabase-Konsole laufen mit Rechten, für die deine Policies nicht gelten, also zeigen sie dir jede Zeile. Die Anfrage deiner App trägt deinen Publishable Key und die Sitzung deines Besuchers, und für sie gelten die Policies. Zwei Sichten auf eine Tabelle, und nur eine davon ist die, die deine Nutzerinnen und Nutzer bekommen.
Diesen Unterschied sieht man von außen, und genau deshalb können wir ihn zählen. Unser Scanner liest Live-Apps so, wie es ein Fremder täte. Hinter 8.435 der Apps, die er benotet hat, steckt ein Supabase-Projekt. Bei 3.680 davon hat die Frage "kann jemand ohne Konto diese Tabelle lesen" überhaupt eine brauchbare Antwort bekommen, und 1.584 davon haben mit Nein geantwortet: ein Fremder, der nach den Zeilen fragte, bekam nichts zurück. Dieses Nichts ist dasselbe Nichts, das deine eigene App bekommt, wenn keine Policy auf sie passt.
Wie du eine versteckte Zeile von einer fehlenden unterscheidest
Setz dieselbe Suche zweimal ab, einmal dort, wo die Policies gelten, und einmal dort, wo sie es nicht tun. Eine Abfrage klärt es.
- Öffne deine Supabase-Konsole, geh in den SQL Editor und hol die Zeile über
denselben Wert, den deine App benutzt hat:
select * from your_table where id = 'the-id-your-app-sent';. Eine Abfrage aus dem SQL Editor läuft über die Rollepostgres, für die deine Policies nicht gelten. - Kommt die Zeile zurück, dann gibt es sie, und irgendetwas zwischen deiner App und der Zeile hat sie entfernt. Dieses Etwas ist eine Policy, und du bist bei Ursache 1. Lies die Policies auf dieser Tabelle.
- Kommt auch dort nichts zurück, fehlt die Zeile wirklich, und du bist bei Ursache 3 weiter unten. Mit deinen Regeln ist nichts verkehrt.
Mach das, bevor du eine Zeile Code änderst. Beide Ursachen erzeugen dasselbe Fehlerobjekt, und die beiden Reparaturen haben nichts gemeinsam.
Wenn du ohnehin gerade die Policies auf dieser Tabelle anschaust, lohnt es sich zu wissen, was sie jemandem ganz ohne Konto sagen. Unser kostenloser Scan liest deine Live-App und sagt dir, welche deiner Tabellen einem Fremden antworten. Er dauert etwa 20 Sekunden und braucht kein Konto: App scannen.
Ursache 2: es kamen mehrere Zeilen zurück
.single() wurde auf eine Suche angewendet, die mehr als eine Zeile treffen
kann.
Eine Suche über einen Primärschlüssel trifft per Definition eine Zeile oder keine. Eine Suche über eine E-Mail, einen Slug, einen Namen, einen Status, einen Fremdschlüssel: jede davon kann zwei treffen. Die übliche Vorgeschichte ist, dass die Spalte in deinen Testdaten einen Wert pro Zeile hielt und damit aufhörte, als echte Menschen kamen.
Die Antwort sagt dir, bei welcher Ursache du bist. Beide Formulierungen der
details-Zeile nennen die Zahl der Zeilen im Ergebnis. Null schickt dich zu
Ursache 1 oder Ursache 3. Eine Zahl über null meint diese hier, und sie ist die
Anzahl der Zeilen, die gepasst haben.
Die Reparatur ist nicht .maybeSingle(). Das akzeptiert null Zeilen und meldet
bei zwei weiterhin einen Fehler, ändert hier also nichts. Entweder schränkst du
die Abfrage über die Spalte, die wirklich eindeutig ist, auf eine Zeile ein, oder
du nimmst hin, dass es mehrere gibt, und liest eine Liste.
Ursache 3: die Zeile ist wirklich weg
Manchmal fehlt die Zeile, und die Abfrage aus dem vorigen Abschnitt ist das, was diesen Fall bestätigt.
Die gewöhnlichen Gründe, in der Reihenfolge, in der sie in einer vibe-codeten App auftauchen. Der Wert, nach dem deine Abfrage gefiltert hat, war leer, weil die id noch nicht geladen war, als die Abfrage lief, also passte nichts. Die Zeile wurde gelöscht, von dir oder von einem Prompt, der mehr umgeschrieben hat als erwartet. Oder die Abfrage nennt eine Tabelle, die unter einem anderen Namen neu gebaut wurde, und die alten Zeilen gingen mit der alten Tabelle.
Nichts davon ist ein Sicherheitsproblem, und keines wird durch eine geänderte Policy behoben. Findet der SQL Editor nichts, hör auf, über Row Level Security zu lesen, und schau dir an, was deine App übergibt.
single oder maybeSingle: was deine Abfrage braucht
.maybeSingle(), wo nichts zu finden eine zulässige Antwort ist. .single(), wo
es das nicht ist.
.single() behandelt jede Zahl von Zeilen außer eins als Fehler. Das ist das
richtige Verhalten für eine Suche über einen Primärschlüssel, wo null Zeilen
heißt, dass es den Datensatz, um den es deinem Code geht, nicht gibt und
Weitermachen schlimmer wäre als Anhalten.
.maybeSingle() akzeptiert null Zeilen: es gibt dir data als null, ohne
Fehler und mit einem 200. Mehr als eine Zeile ist weiterhin ein Fehler, mit
demselben Code PGRST116. Es passt also zu einer Profilzeile, die vielleicht noch
nicht angelegt wurde, oder zu einem optionalen Datensatz, für den deine Seite
einen leeren Zustand rendert.
Eine Sache, bei der du aufpassen musst. .single() durch .maybeSingle() zu
ersetzen ändert, wie deine App sich verhält, und ändert nichts an dem, was die
Datenbank geschickt hat. Hat eine Policy die Zeile versteckt, hast du einen
sichtbaren Fehler in eine Seite verwandelt, die so rendert, als gäbe es den
Datensatz nicht, und zwar für genau die eine Person, die ihn sehen sollte. Der
Fehler hat dir etwas gesagt. Tausch erst nach dem Test oben, wenn du weißt, dass
null die ehrliche Antwort ist.
Deine Regeln im Blick behalten, nachdem du sie geändert hast
Eine Policy, die du aufmachst, um diesen Fehler loszuwerden, ist eine Änderung, die von innen in deiner App niemand sieht. Deine eigene Sitzung läuft weiter, der Fehler ist weg, und ob diese Tabelle jetzt einem Fremden antwortet, muss jemand hingehen und nachfragen.
Reeve Monitor fragt für dich nach:
- alle neun Checks jede Stunde, für bis zu drei Apps
- eine E-Mail an dem Tag, an dem ein erneuter Scan deine App schlechter benotet als der vorige
- ob die App erreichbar ist, alle 60 Sekunden
- ein Monatsbericht über das Gesehene
Eine Regel, die weit genug gelockert wird, um einen Fehler loszuwerden, kann einen Fremden Zeilen auch ändern lassen, nicht nur lesen. Reeve Care hält eine Kopie deiner Supabase-Datenbank dort, wo keine Änderung an deinen Regeln sie erreicht.
- eine verschlüsselte Kopie jeden Tag, außerhalb deines Supabase-Kontos aufbewahrt
- jede Kopie geprüft, bevor sie zählt, durch Zählen der Zeilen in jeder Tabelle
- eine Wiederherstellung per Klick, die sichert, was gerade da ist, bevor sie es ersetzt
- deine hochgeladenen Dateien ebenso, sobald du einen Storage-Zugang verbindest
- alles, was Monitor kann
Was heute zu tun ist
Was zu tun ist
- Geh nach dem Feld
code.PGRST116kommt unter zwei verschiedenen Sätzen an, und welchen du bekommst, hängt von der PostgREST-Version deines Projekts ab. - Lies die Zeilenzahl in der
details-Zeile. Null heißt leeres Ergebnis. Alles über null heißt, deine Suche hat mehrere Zeilen getroffen und braucht eine eindeutige Spalte. - Setz bei einem leeren Ergebnis dieselbe Suche im SQL Editor ab, bevor du Code änderst. Kommt die Zeile dort zurück und in deiner App nicht, hat eine Policy sie entfernt.
- Wechsle zu
.maybeSingle(), sobald du weißt, dass null die ehrliche Antwort ist. Bei einer Zeile, die eine Policy versteckt, ersetzt es einen Fehler durch eine leere Seite, ohne Warnung. - Nachdem du eine Policy geändert hast, um das loszuwerden, prüf, was diese Tabelle jetzt jemandem ohne Konto zurückgibt.
Fang mit einer Abfrage im SQL Editor an, denn sie entscheidet, welche Hälfte dieses Artikels für dich gilt. Stellt sich heraus, dass eine Policy die Antwort war, zeigt Row Level Security für jede Tabelle anschalten, wie man eine Regel schreibt, die die richtige Person durchlässt, und "new row violates row-level security policy" ist die Meldung, die du bekommst, wenn eine Policy ein Schreiben ablehnt, das du mit eigenen Augen siehst.
FAQ
Was bedeutet PGRST116?
Es bedeutet, dass deine Abfrage die Datenbank nach genau einer Zeile gefragt hat und das Ergebnis eine andere Zahl enthielt. PostgREST, der Teil von Supabase, der deine App über HTTP bedient, gibt das als HTTP 406 mit dem Code PGRST116 zurück. Die Zahl steht in der details-Zeile der Antwort, und fast immer ist sie null.
Warum gibt meine Abfrage keine Zeile zurück, obwohl ich die Zeile in Supabase sehe?
Weil der SQL Editor in deiner Konsole mit Rechten läuft, für die deine Row-Level-Security-Policies nicht gelten, und deine App nicht. Erlaubt keine Policy deinem Besucher, diese Zeile zu lesen, wird sie aus seinem Ergebnis entfernt, bevor das Ergebnis gezählt wird, und zurück kommt eine Null. Die Zeile ist da. Sie war nie in der Antwort.
Was ist der Unterschied zwischen single und maybeSingle?
single() behandelt jede Zahl außer genau eins als Fehler. maybeSingle() akzeptiert null Zeilen und gibt dir data als null zurück, ohne Fehler, und meldet weiterhin einen Fehler, wenn mehr als eine Zeile passt. maybeSingle() passt also dort, wo nichts zu finden eine zulässige Antwort ist, etwa bei einem Profil, das es noch nicht gibt, und single() passt zu einer Suche über eine Spalte, die nur eine einzige Zeile treffen kann.
Löst Row Level Security diesen Fehler aus?
Das ist die häufigste Ursache in einer App, die mit Lovable, Bolt, Replit oder v0 gebaut wurde. Row Level Security filtert Zeilen aus einem Ergebnis heraus, statt die Anfrage abzulehnen, also ist eine Zeile, die niemand lesen darf, von einer Zeile, die es nicht gibt, nicht zu unterscheiden. Beide ergeben eine Null, und eine Abfrage, die genau eine Zeile verlangt, scheitert an beidem.
Warum ist es ein 406?
406 Not Acceptable ist der Status für eine Anfrage, die der Server nicht in der verlangten Form beantworten kann. Deine Abfrage trug einen Accept-Header, der sagte, die Antwort müsse ein einzelner Datensatz sein statt einer Liste, und ohne Zeile darin gibt es keine akzeptable Antwort. Lass den Header weg, und dieselbe Abfrage gibt eine leere Liste mit einem 200 zurück.