Security basics
Supabase "multiple (or no) rows returned" usually means no rows
Supabase returns PGRST116, "JSON object requested, multiple (or no) rows returned", when your query found none. A row your rules hide looks missing.

In short
- "JSON object requested, multiple (or no) rows returned" is error PGRST116, and it means your query came back with a number of rows other than one. Nearly always that number is zero.
- With row level security switched on, a row your visitor is not allowed to read comes back as no row at all. So a record you can see in the Supabase table editor can be absent from your app while the data is perfectly fine.
- Use maybeSingle() where finding nothing is a legal answer and single() where it is not. Run one query in the SQL editor first, so you know which of the two you have.
Your app tells somebody a record cannot be found. You open the Supabase table editor and the record is sitting right there, in the table, with the right id.
What came back from the database was this:
{
"code": "PGRST116",
"message": "JSON object requested, multiple (or no) rows returned",
"details": "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row"
}
Here is the part that guide after guide gets wrong: zero rows is an answer your
own rules can produce, and it reads exactly like data that is gone. Search this
message and you are told to swap .single() for .maybeSingle(). That does stop
the error. It tells you nothing about whether the row was missing or whether your
visitor was never allowed to see it, and those two have different repairs.
What does "JSON object requested, multiple (or no) rows returned" mean?
Your query asked the database for exactly one row and the result held a different number. Nearly always that number is zero.
Think of a reading room where you fill in a slip and somebody behind the counter fetches the book. Your slip says: bring me the one book with this number. The counter comes back and tells you there is nothing matching the slip. That is the only answer the counter gives, and it is the same answer whether the book was never in the catalogue or whether it is on a shelf you are not cleared for. Nobody is being coy with you. The slip system was built so that what you are not cleared for is simply absent from your view of the catalogue.
Your app is the one holding the slip, and the number the counter came back with
is written in the details line of the reply.
Two different messages, and the same error code
The code is stable and the sentence is not, so match on PGRST116.
Two wordings are in circulation for this one error, and which you see depends on
which version of PostgREST your project runs. PostgREST is the server that turns
your Supabase tables into the HTTP API your app talks to. Its documentation for
version 12 gives the message as JSON object requested, multiple (or no) rows returned, with a details line reading
Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row.
In versions 13 and 14 the same situation is documented as
Cannot coerce the result to a single JSON object, with details reading
The result contains 0 rows.
The older sentence has a second life beyond old projects. When you call
.maybeSingle() and more than one row matches, the Supabase JavaScript client
writes the error itself rather than passing one on from the server, and the
message it writes is the older wording. So the first sentence is still arriving
in apps whose database would have sent the second one.
What does not move is the code. PostgREST's error table lists PGRST116 at 406
in all three versions, with the same description: more than one row, or none,
when exactly one was requested. Read the code field and ignore the prose.
Why .single() produces this at all
Because .single() is an instruction about the shape of the reply rather than a
filter on the data.
When you write .single(), the Supabase client adds one header to the request:
Accept: application/vnd.pgrst.object+json. That header asks the database to
send the row on its own, as a record. It is what lets your code write data.name
where it would otherwise have to write data[0].name.
The database honours it strictly. One row goes back as a record. Any other number
of rows cannot be sent in that shape at all, so the reply is a 406 and an error
object. Drop the .single() and the identical query returns [] with a 200 and
no error anywhere.
So nothing had to break to produce this. An empty result became an error because your query said the reply had to hold exactly one record.
Cause 1: the row is there and your rules say no
Row level security does not refuse your query. It takes rows out of it, and a row taken out of a result was never in the result to be counted.
Row level security is the set of rules on each Supabase table that decides, row by
row, which rows a given visitor is allowed to read. When no rule allows your
visitor to read the row your query named, that row is removed before the result is
measured. The count is zero. Your .single() wanted one. You get PGRST116, and
no part of the reply mentions that a rule was involved.
That is the reading room again, and it is why the counter says the same thing twice. Your clearance is applied before the shelf is even searched, so a book you cannot borrow and a book that does not exist produce one identical slip coming back.
This is also why the table editor misleads you. The SQL editor and the table editor in your Supabase dashboard run with privileges your policies do not apply to, so they show you every row. Your app's request carries your publishable key and your visitor's session, and the policies do apply to it. Two views of one table, and only one of them is the view your users get.
That difference is readable from outside, which is how we can count it. Our scanner reads live apps the way a stranger would. Behind 8,435 of the apps it has graded there is a Supabase project. On 3,680 of those, the question "can somebody with no account read this table" got a usable answer at all, and 1,584 of them answered no: a stranger asking for the rows got nothing back. That nothing is the same nothing your own app receives when a policy does not match it.
How to tell a hidden row from a missing one
Run the same lookup twice, once where the policies apply and once where they do not. One query settles it.
- Open your Supabase dashboard, go to the SQL editor, and select the row by
the same value your app used:
select * from your_table where id = 'the-id-your-app-sent';. A query from the SQL editor is run through thepostgresrole, which your policies do not apply to. - If that returns the row, the row exists and something between your app and the row removed it. That something is a policy, and you are in cause 1. Read the policies on that table.
- If that returns nothing either, the row is genuinely absent and you are in cause 3 below. Nothing is wrong with your rules.
Do this before you change a line of code. Both causes produce the same error object, and the two repairs have nothing in common.
While you are looking at the policies on that table, it is worth knowing what they currently say to somebody with no account at all. Our free scan reads your live app and tells you which of your tables answer a stranger. It takes about 20 seconds and needs no account: scan your app.
Cause 2: more than one row came back
.single() was used on a lookup that can match more than one row.
A lookup by a primary key matches one row or none, by definition. A lookup by email, by slug, by name, by a status, by a foreign key: every one of those can match two. The usual history is that the column held one value per row in your test data and stopped doing so once real people arrived.
The reply tells you which cause you are in. Both wordings of the details line
state how many rows the result contained. Zero sends you to cause 1 or cause 3. A
number above zero means this one, and it is the count of rows that matched.
The repair is not .maybeSingle(). That accepts zero rows and still errors on
two, so it changes nothing here. Either filter the query down to one row with the
column that is genuinely unique, or accept that there are several and read a list.
Cause 3: the row really is gone
Sometimes the row is absent, and the query in the previous section is what rules it in.
The ordinary reasons, in the order they turn up in a vibe-coded app. The value your query filtered on was empty, because the id had not loaded when the query ran, so nothing matched. The row was deleted, by you or by a prompt that rewrote more than you expected. Or the query names a table that was rebuilt under a different name and the old rows went with the old table.
None of those is a security problem, and none of them is fixed by changing a policy. If the SQL editor finds nothing, stop reading about row level security and go and look at what your app is passing in.
single or maybeSingle: which one your query wants
.maybeSingle() where finding nothing is a legal answer. .single() where it is
not.
.single() treats any number of rows other than one as an error. That is the
right behaviour for a lookup by a primary key, where zero rows means the record
your code is about does not exist and carrying on would be worse than stopping.
.maybeSingle() accepts zero rows: it hands you data as null, with no error
and a 200. More than one row is still an error, with the same PGRST116 code. So
it fits a profile row that may not have been created yet, or an optional record
your page renders a blank state for.
One thing to be careful about. Swapping .single() for .maybeSingle() changes
how your app behaves and changes nothing about what the database sent. If
a policy was hiding the row, you have turned a visible error into a page that
renders as though the record does not exist, for the one person who was supposed
to see it. The error was telling you something. Make the swap after the test
above, once you know zero is the honest answer.
Keeping an eye on your rules after you change them
A policy you widen to clear this error is a change nobody sees from inside your app. Your own session goes on working, the error is gone, and whether that table now answers a stranger is a question somebody has to go and ask.
Reeve Monitor asks it for you:
- all nine checks every hour, on up to three apps
- an email the day a re-scan grades your app lower than the one before
- whether the app is up, every 60 seconds
- a monthly report of what it saw
A rule relaxed far enough to clear an error can let a stranger change rows as well as read them. Reeve Care keeps a copy of your Supabase database where no change to your rules can reach it.
- an encrypted copy every day, kept outside your Supabase account
- each copy verified before it counts, by counting the rows in every table
- a one-click restore that saves what is there now before it replaces anything
- your uploaded files as well, once you connect a Storage credential
- everything Monitor does
What to do today
What to do
- Match on the
codefield.PGRST116arrives under two different sentences, and which one you get depends on the PostgREST version your project runs. - Read the row count in the
detailsline. Zero means an empty result. Anything above zero means your lookup matched several rows and needs a column that is unique. - On an empty result, run the same lookup in the SQL editor before you change any code. If the row comes back there and not in your app, a policy removed it.
- Switch to
.maybeSingle()once you know zero is the honest answer. On a row a policy is hiding, it replaces an error with a blank page and no warning. - After you change a policy to clear this, check what that table now returns to somebody with no account.
Start with one query in the SQL editor, because it decides which half of this article applies to you. If it turns out a policy was the answer, turning row level security on for every table covers writing a rule that lets the right person through, and "new row violates row-level security policy" is the message you get when a policy refuses a write you can see with your own eyes.
FAQ
What does PGRST116 mean?
It means your query asked the database for exactly one row and the result held a different number. PostgREST, the part of Supabase that answers your app over HTTP, returns it as HTTP 406 with the code PGRST116. The count is in the details line of the reply, and nearly always it is zero.
Why does my query return no rows when I can see the row in Supabase?
Because the SQL editor in your dashboard runs with privileges your row level security policies do not apply to, and your app does not. If no policy allows your visitor to read that row, the row is removed from their result before the result is counted, and what comes back is a count of zero. The row is there. It was never in the reply.
What is the difference between single and maybeSingle?
single() treats anything other than exactly one row as an error. maybeSingle() accepts zero rows and hands you data as null with no error, and it still errors when more than one row matches. So maybeSingle() is right where finding nothing is a legal answer, such as a profile that may not exist yet, and single() is right for a lookup by a column that can only ever match one row.
Does row level security cause this error?
It is the commonest cause in an app built with Lovable, Bolt, Replit or v0. Row level security filters rows out of a result rather than refusing the request, so a row nobody is allowed to read is indistinguishable from a row that does not exist. Both produce a count of zero, and a query asking for exactly one row fails on both.
Why is it a 406?
406 Not Acceptable is the status for a request the server cannot answer in the form that was asked for. Your query carried an Accept header saying the reply had to be one record rather than a list, and with no row to put in it there is no acceptable reply to send. Drop that header and the same query returns an empty list with a 200.