Saltar al contenido

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.

Vlad Tkachenko11 min de lectura
Una papeleta de solicitud y una bandeja de devolución vacía sobre un mostrador, y detrás una estantería con una fila destacada en teal tras una barra ámbar.

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.

Una fila que tus reglas ocultan y una fila que no está dejan el resultado vacío igual. La respuesta cuenta filas, así que las dos llegan como el mismo error.

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.

  1. 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 rol postgres, al que tus policies no se aplican.
  2. 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.
  3. 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.

La única diferencia está en qué hace cada uno con un resultado vacío. Los dos rechazan dos filas, y ninguno cambia lo que la base devolvió de verdad.

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. PGRST116 llega 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.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

Haz una copia de tu base de datos de Supabase antes del próximo cambio

Reeve la guarda cifrada con regularidad y la restaura por ti si algo falla.

Ver copias de Supabase

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.