Fundamentos de seguridad
Supabase "multiple (or no) rows returned" casi siempre es ninguna
Supabase devuelve PGRST116, "JSON object requested, multiple (or no) rows returned", cuando tu consulta no encontró nada. Una fila oculta parece ausente.

En resumen
- "JSON object requested, multiple (or no) rows returned" es el error PGRST116, y significa que tu consulta volvió con un número de filas distinto de uno. Casi siempre ese número es cero.
- Con Row Level Security encendida, una fila que tu visitante no puede leer vuelve como ninguna fila. Así que un registro que ves en el Table Editor de Supabase puede faltar en tu app mientras los datos están perfectamente bien.
- Usa maybeSingle() donde no encontrar nada sea una respuesta legítima y single() donde no lo sea. Lanza antes una consulta en el SQL Editor, para saber cuál de los dos casos tienes.
Tu app le dice a alguien que un registro no se encuentra. Abres el Table Editor de Supabase y el registro está ahí, en la tabla, con el id correcto.
Esto es lo que volvió 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"
}
Y esta es la parte que guía tras guía se salta: cero filas es una respuesta que
tus propias reglas pueden producir, y se lee igual que unos datos que ya no
están. Busca este mensaje y te dirán que cambies .single() por
.maybeSingle(). Eso sí detiene el error. No te dice nada sobre si la fila
faltaba o si tu visitante nunca tuvo permiso para verla, y esos dos casos se
reparan distinto.
¿Qué significa "JSON object requested, multiple (or no) rows returned"?
Tu consulta le pidió a la base exactamente una fila y el resultado tenía otro número. Casi siempre ese número es cero.
Piensa en una sala de lectura donde rellenas una papeleta y alguien detrás del mostrador va a buscar el libro. Tu papeleta dice: tráeme el único libro con este número. El mostrador vuelve y te dice que no hay nada que coincida con la papeleta. Esa es la única respuesta que da el mostrador, y es la misma tanto si el libro nunca estuvo en el catálogo como si está en una estantería para la que no tienes permiso. Nadie está jugando contigo. El sistema de papeletas se construyó para que aquello para lo que no tienes permiso sencillamente no aparezca en tu vista del catálogo.
Tu app es la que sostiene la papeleta, y el número con el que volvió el mostrador
está escrito en la línea details de la respuesta.
Dos mensajes distintos, y el mismo código de error
El código es estable y la frase no, así que guíate por PGRST116.
Hay dos redacciones en circulación para este único error, y cuál ves depende de
qué versión de PostgREST corre tu proyecto. PostgREST es el servidor que convierte
tus tablas de Supabase en la API HTTP con la que habla tu app. Su documentación
de la versión 12 da el mensaje
JSON object requested, multiple (or no) rows returned, con una línea details
que dice
Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row. En
las versiones 13 y 14 la misma situación está documentada como
Cannot coerce the result to a single JSON object, con details diciendo
The result contains 0 rows.
La frase antigua tiene una segunda vida más allá de los proyectos viejos. Cuando
llamas a .maybeSingle() y coincide más de una fila, el cliente JavaScript de
Supabase escribe el error él mismo en lugar de pasar uno del servidor, y el
mensaje que escribe es la redacción antigua. Así que la primera frase sigue
llegando a apps cuya base habría enviado la segunda.
Lo que no se mueve es el código. La tabla de errores de PostgREST lista
PGRST116 en 406 en las tres versiones, con la misma descripción: más de una
fila, o ninguna, cuando se pedía exactamente una. Lee el campo code e ignora la
prosa.
Por qué .single() produce esto
Porque .single() es una instrucción sobre la forma de la respuesta y no un
filtro sobre los datos.
Cuando escribes .single(), el cliente de Supabase añade una cabecera a la
petición: Accept: application/vnd.pgrst.object+json. Esa cabecera le pide a la
base que envíe la fila sola, como un registro. Es lo que permite que tu código
escriba data.name donde si no tendría que escribir data[0].name.
La base lo cumple a rajatabla. Una fila vuelve como un registro. Cualquier otro
número de filas no se puede enviar en esa forma, así que la respuesta es un 406 y
un objeto de error. Quita el .single() y la consulta idéntica devuelve [] con
un 200 y ningún error en ninguna parte.
Así que no hizo falta que nada se rompiera para producir esto. Un resultado vacío se convirtió en un error porque tu consulta dijo que la respuesta tenía que contener exactamente un registro.
Causa 1: la fila está y tus reglas dicen que no
Row Level Security no rechaza tu consulta. Le saca filas, y una fila sacada nunca estuvo en el resultado que se cuenta.
Row Level Security es el conjunto de reglas sobre cada tabla de Supabase que
decide, fila por fila, qué filas puede leer un visitante dado. Cuando ninguna
regla permite a tu visitante leer la fila que tu consulta nombró, esa fila se
quita antes de medir el resultado. El conteo es cero. Tu .single() quería una.
Obtienes PGRST116, y ninguna parte de la respuesta menciona que hubo una regla
de por medio.
Esa es otra vez la sala de lectura, y por eso el mostrador dice lo mismo dos veces. Tu permiso se aplica antes de que siquiera se busque en la estantería, así que un libro que no puedes llevarte y un libro que no existe producen una papeleta idéntica de vuelta.
Por eso también el Table Editor te despista. El SQL Editor y el Table Editor de tu consola de Supabase corren con privilegios a los que tus policies no se aplican, así que te enseñan todas las filas. La petición de tu app lleva tu publishable key y la sesión de tu visitante, y a ella sí se le aplican las policies. Dos vistas de una misma tabla, y solo una es la que reciben tus usuarios.
Esa diferencia se lee desde fuera, y así es como podemos contarla. Nuestro escáner lee apps en vivo como lo haría un desconocido. Detrás de 8.435 de las apps que ha calificado hay un proyecto Supabase. En 3.680 de ellas la pregunta "¿puede alguien sin cuenta leer esta tabla?" obtuvo una respuesta utilizable, y 1.584 respondieron que no: un desconocido que pidió las filas no recibió nada. Esa nada es la misma nada que recibe tu propia app cuando ninguna policy le encaja.
Cómo distinguir una fila oculta de una que falta
Lanza la misma búsqueda dos veces, una donde las policies se aplican y otra donde no. Una consulta lo zanja.
- Abre tu consola de Supabase, ve al SQL Editor y saca la fila por el mismo
valor que usó tu app:
select * from your_table where id = 'the-id-your-app-sent';. Una consulta desde el SQL Editor corre a través del rolpostgres, al que tus policies no se aplican. - Si devuelve la fila, la fila existe y algo entre tu app y la fila la quitó. Ese algo es una policy, y estás en la causa 1. Lee las policies de esa tabla.
- Si tampoco devuelve nada, la fila falta de verdad y estás en la causa 3 de más abajo. Tus reglas no tienen nada que ver.
Haz esto antes de cambiar una línea de código. Las dos causas producen el mismo objeto de error, y las dos reparaciones no se parecen en nada.
Ya que estás mirando las policies de esa tabla, vale la pena saber qué le dicen hoy a alguien sin ninguna cuenta. Nuestro escaneo gratuito lee tu app en vivo y te dice cuáles de tus tablas le responden a un desconocido. Tarda unos 20 segundos y no necesita cuenta: escanear tu app.
Causa 2: volvieron varias filas
.single() se usó sobre una búsqueda que puede coincidir con más de una fila.
Una búsqueda por clave primaria coincide con una fila o con ninguna, por definición. Una búsqueda por email, por slug, por nombre, por estado, por clave foránea: cualquiera de ellas puede tocar dos. La historia habitual es que la columna tenía un valor por fila en tus datos de prueba y dejó de tenerlo cuando llegó gente de verdad.
La respuesta te dice en qué causa estás. Las dos redacciones de la línea
details dicen cuántas filas tenía el resultado. Cero te manda a la causa 1 o a
la causa 3. Un número por encima de cero señala esta, y es el conteo de filas que
coincidieron.
La reparación no es .maybeSingle(). Ese acepta cero filas y sigue dando error
con dos, así que aquí no cambia nada. O acotas la consulta a una fila con la
columna que de verdad es única, o aceptas que hay varias y lees una lista.
Causa 3: la fila ya no está
A veces la fila falta, y la consulta de la sección anterior es lo que lo confirma.
Las razones corrientes, en el orden en que aparecen en una app vibe-codeada. El valor por el que filtraba tu consulta estaba vacío, porque el id no había cargado cuando la consulta salió, así que no coincidió nada. La fila se borró, por ti o por un prompt que reescribió más de lo que esperabas. O la consulta nombra una tabla que se reconstruyó con otro nombre y las filas viejas se fueron con la tabla vieja.
Nada de eso es un problema de seguridad, y ninguno se arregla cambiando una policy. Si el SQL Editor no encuentra nada, deja de leer sobre Row Level Security y ve a mirar qué está pasando tu app.
single o maybeSingle: cuál quiere tu consulta
.maybeSingle() donde no encontrar nada es una respuesta legítima. .single()
donde no lo es.
.single() trata cualquier número de filas distinto de uno como un error. Es el
comportamiento correcto para una búsqueda por clave primaria, donde cero filas
significa que el registro del que trata tu código no existe y seguir sería peor
que parar.
.maybeSingle() acepta cero filas: te entrega data como null, sin error y
con un 200. Más de una fila sigue siendo un error, con el mismo código
PGRST116. Así que encaja con una fila de perfil que puede no haberse creado
todavía, o con un registro opcional para el que tu página pinta un estado vacío.
Una cosa con la que hay que tener cuidado. Cambiar .single() por
.maybeSingle() cambia cómo se comporta tu app y no cambia nada de lo que envió
la base. Si una policy escondía la fila, has convertido un error visible en una
página que se pinta como si el registro no existiera, para la única persona que
tenía que verlo. El error te estaba diciendo algo. Haz el cambio después de la
prueba de arriba, cuando sepas que cero es la respuesta honesta.
Vigilar tus reglas después de cambiarlas
Una policy que abres para quitarte este error es un cambio que nadie ve desde dentro de tu app. Tu propia sesión sigue funcionando, el error ya no está, y si esa tabla ahora le responde a un desconocido es una pregunta que alguien tiene que ir a hacer.
Reeve Monitor la hace por ti:
- las nueve comprobaciones cada hora, en hasta tres apps
- un email el día que un nuevo escaneo califica tu app peor que el anterior
- si la app responde, cada 60 segundos
- un informe mensual de lo que vio
Una regla aflojada lo bastante como para quitar un error puede dejar que un desconocido cambie filas además de leerlas. Reeve Care guarda una copia de tu base de datos de Supabase donde ningún cambio en tus reglas puede alcanzarla.
- una copia cifrada cada día, guardada fuera de tu cuenta de Supabase
- cada copia verificada antes de contar, contando las filas de cada tabla
- una restauración en un clic que guarda lo que hay ahora antes de reemplazarlo
- tus archivos subidos también, en cuanto conectes un acceso de Storage
- todo lo que hace Monitor
Qué hacer hoy
Qué hacer
- Guíate por el campo
code.PGRST116llega bajo dos frases distintas, y cuál te toca depende de la versión de PostgREST que corre tu proyecto. - Lee el conteo de filas en la línea
details. Cero significa resultado vacío. Por encima de cero, tu búsqueda tocó varias filas y necesita una columna única. - Con un resultado vacío, lanza la misma búsqueda en el SQL Editor antes de cambiar código. Si la fila vuelve ahí y no en tu app, una policy la quitó.
- Pásate a
.maybeSingle()cuando sepas que cero es la respuesta honesta. Sobre una fila que una policy esconde, reemplaza un error por una página vacía y sin aviso. - Después de cambiar una policy para quitarte esto, comprueba qué devuelve ahora esa tabla a alguien sin cuenta.
Empieza por una consulta en el SQL Editor, porque es la que decide qué mitad de este artículo te toca. Si resulta que una policy era la respuesta, activar Row Level Security en cada tabla enseña a escribir una regla que deje pasar a la persona correcta, y "new row violates row-level security policy" es el mensaje que recibes cuando una policy rechaza una escritura que ves con tus propios ojos.
Preguntas frecuentes
¿Qué significa PGRST116?
Significa que tu consulta le pidió a la base exactamente una fila y el resultado tenía otro número. PostgREST, la parte de Supabase que responde a tu app por HTTP, lo devuelve como HTTP 406 con el código PGRST116. El número está en la línea details de la respuesta, y casi siempre es cero.
¿Por qué mi consulta no devuelve filas si veo la fila en Supabase?
Porque el SQL Editor de tu consola corre con privilegios a los que tus policies de Row Level Security no se aplican, y tu app no. Si ninguna policy permite a tu visitante leer esa fila, la fila se quita de su resultado antes de contarlo, y lo que vuelve es un cero. La fila está ahí. Nunca estuvo en la respuesta.
¿Cuál es la diferencia entre single y maybeSingle?
single() trata cualquier número de filas distinto de uno como un error. maybeSingle() acepta cero filas y te entrega data como null, sin error, y sigue dando error cuando coincide más de una fila. Así que maybeSingle() encaja donde no encontrar nada es una respuesta legítima, como un perfil que todavía no existe, y single() encaja en una búsqueda por una columna que solo puede coincidir con una fila.
¿Row Level Security causa este error?
Es la causa más común en una app construida con Lovable, Bolt, Replit o v0. Row Level Security filtra filas fuera del resultado en lugar de rechazar la petición, así que una fila que nadie puede leer no se distingue de una fila que no existe. Las dos dan un conteo de cero, y una consulta que exige exactamente una fila falla con ambas.
¿Por qué es un 406?
406 Not Acceptable es el estado de una petición que el servidor no puede responder en la forma pedida. Tu consulta llevaba una cabecera Accept que decía que la respuesta debía ser un registro único y no una lista, y sin ninguna fila que poner dentro no hay respuesta aceptable que enviar. Quita esa cabecera y la misma consulta devuelve una lista vacía con un 200.