Saltar al contenido

Fundamentos de seguridad

Supabase "relation does not exist" aunque la tabla está ahí

Supabase "relation does not exist" o PGRST205: ninguna tabla tiene ese nombre en ese esquema. Cuatro comprobaciones, y lo que abre exponer un esquema.

Vlad Tkachenko12 min de lectura
Un edificio en sección junto a un hueco de ascensor. El ascensor está en la planta baja, y su parada un piso más arriba es un hueco ámbar discontinuo.

En resumen

  • Supabase "relation does not exist" (código de Postgres 42P01) significa que no hay ninguna tabla ni vista con ese nombre exacto en los esquemas que recorrió la consulta. Desde tu app, en un proyecto actual, el mismo problema se lee "Could not find the table in the schema cache" (PGRST205).
  • Comprueba primero el nombre letra por letra, mayúsculas incluidas, luego el esquema y luego el proyecto. Dentro de una función con el search_path vacío, cada tabla necesita su esquema delante: public.invoices.
  • Exponer un esquema hace que el error desaparezca y deja que la API llegue a todas las tablas que contiene, así que revisa la Row Level Security de cada una antes de hacerlo.

Tu builder añadió una tabla invoices. La ves en el Table Editor, con sus filas, y la página que debería listarlas se queda vacía. La consola del navegador muestra esto:

{
  "code": "PGRST205",
  "details": null,
  "hint": "Perhaps you meant the table 'public.Invoices'",
  "message": "Could not find the table 'public.invoices' in the schema cache"
}

Supabase "relation does not exist" significa que Postgres no encontró ninguna tabla ni vista con ese nombre exacto en los esquemas que recorrió la consulta. PGRST205 es la misma respuesta dada por PostgREST, la parte de Supabase que convierte las llamadas supabase.from(...) de tu app en consultas a la base de datos, antes incluso de que la consulta llegue a Postgres.

La solución que circula para este error es añadir el esquema a Exposed schemas en los ajustes del proyecto. Eso arregla una de las cuatro causas de abajo. También deja que cualquiera con la clave pública de tu app llegue a todas las tablas de ese esquema, así que lee lo que abre antes de activarlo.

¿Qué significa "relation does not exist" en Supabase?

La base de datos buscó una tabla con ese nombre exacto, en los esquemas que le dijeron que recorriera, y no encontró ninguna. "Relation" es la palabra de Postgres para cualquier cosa de la que salen filas, una tabla o una vista. El código del error es 42P01, al que Postgres llama undefined_table.

Piensa en tu base de datos como un edificio. Cada esquema es una planta: public es la planta baja, y auth, donde Supabase guarda a tus usuarios, es otra. Cada tabla es una sala con su nombre en la puerta. Una consulta que da solo el nombre de una sala la busca en las plantas que figuran en su ruta de búsqueda. Las peticiones de tu app suben en un ascensor, y el ascensor solo para en las plantas que tu proyecto expone. El error significa que nadie encontró una puerta con ese nombre en las plantas que revisó.

La redacción que ves depende de dónde se ejecutó la consulta:

Dónde se ejecutó la consultaQué vuelve
El SQL Editor, una migración, una función o un triggerrelation "invoices" does not exist, código 42P01
Tu app, con PostgREST 13 o posteriorCould not find the table 'public.invoices' in the schema cache, código PGRST205, HTTP 404
Tu app, con un PostgREST más antiguouna lectura recibe relation "public.invoices" does not exist, HTTP 404, y un insert un error vacío
Tu app, pidiendo un esquema que el proyecto no exponeInvalid schema: billing, código PGRST106, HTTP 406

El changelog de PostgREST fecha la redacción nueva en la versión 13.0.0, publicada el 8 de mayo de 2025: "Querying non-existent table now returns PGRST205 error". Antes, como explicó uno de sus mantenedores en el issue #3697, una lectura iba a Postgres sin comprobar y volvía como 42P01. La guía de Supabase para proteger la Data API dice que puedes consultar la versión de PostgREST de tu proyecto en el Dashboard. El propio código de PostgREST fija el resto: la pista de PGRST205, cuando la hay, nombra la tabla más parecida que conoce en la misma planta.

Si el mensaje dice en cambio permission denied for table, la tabla se encontró y a tu app la rechazaron en la puerta. Ese tiene otro arreglo.

Causa 1: el nombre de la puerta lleva una mayúscula

El nombre tiene que coincidir exactamente, mayúsculas incluidas, y dos de las herramientas que usas tratan las mayúsculas de forma distinta a la tercera.

La documentación de Postgres resume la regla en una frase: "Quoting an identifier also makes it case-sensitive, whereas unquoted names are always folded to lower case." Las otras dos conservan lo que escribiste:

  • el Table Editor de Supabase crea la tabla con exactamente el nombre que le das. Su código pone el nombre entre comillas cuando hace falta, así que Invoices sigue siendo Invoices
  • PostgREST pone entre comillas dobles cada nombre que envía tu app (pgFmtIdent), así que .from('invoices') pide invoices y nada más
Cómo se creó la tablaEn SQL, escribeEn tu app, escribe
create table invoices, o Invoices escrito en SQL sin comillasinvoices.from('invoices')
create table "Invoices", o Invoices escrito en el Table Editor"Invoices", con comillas.from('Invoices')

La segunda fila produce este error por los dos lados. Tu app llama a .from('invoices') y recibe PGRST205, con el nombre real en la pista, como en el mensaje del principio de esta página. O la app funciona, y una función o una policy que nombra Invoices sin comillas falla con relation "invoices" does not exist, porque Postgres pasó el nombre a minúsculas antes de buscar.

Para ver todas las tablas con ese nombre, en cualquier planta y con cualquier combinación de mayúsculas, ejecuta esto en el SQL Editor:

select table_schema, table_name
from information_schema.tables
where table_name ilike 'invoices';

El arreglo que no toca nada de tus datos está en el código: escribe "Invoices" con sus comillas en SQL, y las mayúsculas exactas en la app. Renombrar la tabla en su lugar rompe cada pantalla que todavía use el nombre antiguo. La versión de este error para columnas tiene la misma trampa con los nombres de columna.

Causa 2: la tabla está en otra planta

Tu app busca en public salvo que le digas otra cosa, así que una tabla de cualquier otro esquema no existe para ella. Por eso el mensaje dice public.invoices: public es la planta a la que fue el ascensor.

Pasa de tres maneras:

  • tu builder creó la tabla en un esquema propio, como billing o private, y la app llama a un simple .from('invoices')
  • el esquema va escrito dentro del nombre, como .from('billing.invoices'). PostgREST lee la cadena entera como un solo nombre de tabla y la busca en public
  • la tabla es auth.users. La guía de Supabase sobre datos de usuario dice: "For security, the Auth schema is not exposed in the auto-generated API." Su respuesta es una tabla tuya en public, profiles en su ejemplo, que llena un trigger cuando alguien se registra, y ese trigger puede fallar a su manera

Una tabla de otra planta se lee con .schema('billing').from('invoices'), y ese esquema tiene que figurar en Exposed schemas en los ajustes de la API del proyecto, como muestra la guía de Supabase sobre esquemas propios. Hasta entonces, la respuesta pasa a ser PGRST106:

{
  "code": "PGRST106",
  "details": null,
  "hint": "Only the following schemas are exposed: public, graphql_public",
  "message": "Invalid schema: billing"
}

La pista enumera las plantas en las que el ascensor sí para. PostgREST 12 redacta el mismo error de otra forma, con la lista dentro del propio mensaje: The schema must be one of the following: public, graphql_public. Es el texto que produce su código para esa versión, y el changelog movió el nombre del esquema al mensaje en la 13.0.7.

Tu clave secreta sube en el mismo ascensor. La exposición es un ajuste de la API, así que el código de servidor que usa supabase-js con esa clave recibe el mismo PGRST106.

El ascensor solo para en las plantas expuestas. Una tabla de otra planta no está en public, y pedir esa planta por su nombre se rechaza hasta que se expone.

¿Qué abre exponer un esquema?

El ascensor pasa a parar en esa planta, y con los permisos que da la guía de Supabase, cada visitante recibe una llave de cada sala que hay en ella. La Row Level Security es entonces lo único que decide qué puede leer o cambiar cada uno.

La guía sobre esquemas propios tiene dos pasos. El primero es el ajuste Exposed schemas. El segundo es un bloque de SQL, y sus dos primeras líneas son:

GRANT USAGE ON SCHEMA myschema TO anon, authenticated, service_role;
GRANT ALL ON ALL TABLES IN SCHEMA myschema TO anon, authenticated, service_role;

anon es el rol que hay detrás de cada petición de alguien que no ha iniciado sesión. ALL es leer, añadir, cambiar y borrar. El bloque sigue con ALTER DEFAULT PRIVILEGES, que da el mismo acceso a cada tabla que se cree después en esa planta.

Eso es seguro donde la Row Level Security, las reglas por fila que Supabase comprueba en cada petición, está activada y escrita para cada tabla. La guía de Supabase para proteger la Data API dice: "Tables created through the Supabase Dashboard have RLS enabled by default. Enable RLS explicitly for tables created in the SQL Editor or through another tool." Un esquema que tu builder montó en SQL puede tener tablas con ella desactivada. Expuesta con esos permisos, una tabla así entrega cada fila a cualquiera que tenga tu clave pública, y además acepta sus cambios.

Compruébalo antes de exponer:

select tablename, rowsecurity
from pg_tables
where schemaname = 'billing';

Cada fila que dice false es una tabla sin ninguna regla. Activar Row Level Security en cada tabla explica cómo activarla y escribir la primera regla, y "RLS disabled in public" explica la regla de lectura abierta que deja una tabla igual de accesible con ella activada.

También puedes dar menos permisos que la guía. Una planta con una tabla que solo leen los usuarios con sesión iniciada necesita dos líneas:

grant usage on schema billing to authenticated;
grant select on billing.invoices to authenticated;

Si tus tablas le responden a un desconocido es algo que se puede comprobar desde fuera. Nuestro escaneo gratuito lee tu app en producción con la misma clave pública que recibe un visitante, te dice qué tablas devuelven filas y no necesita cuenta.

Exponer una planta con los permisos de la guía abre todas sus puertas. La Row Level Security es el candado de cada sala, y una sala sin candado está abierta a cualquiera con tu clave pública.

¿Exponer el esquema o mover la tabla?

Decide según quién lee la tabla. Una tabla que tu app lee desde el navegador necesita un permiso para el rol que la lee y Row Level Security con una regla, esté donde esté. Moverla a public te ahorra el paso de exponer el esquema, y nada más.

Si la mueves:

alter table billing.invoices set schema public;

La tabla conserva sus permisos y sus reglas. Una función cuyo cuerpo nombra billing.invoices falla después con este mismo error, porque el cuerpo guarda el nombre tal como se escribió.

Una tabla que solo necesita el código de servidor puede quedarse en una planta a la que el ascensor nunca va. Una función en public puede hacer con ella la única tarea que tu app necesita, y llamar a una función de Supabase explica cómo quitar execute a los visitantes que no han iniciado sesión.

Causa 3: una función con la ruta de búsqueda vacía

Una función con search_path = '' no recorre ninguna planta, así que cada tabla que usa necesita su esquema delante: public.invoices, nunca un invoices a secas.

El Security Advisor de Supabase pide justo ese ajuste. Su aviso function_search_path_mutable dice: "We recommend pinning functions' search_path to an empty string". La misma página termina con: "once you set the search_path = '' all references to tables/functions/views/etc in your function's body must be qualified with a schema name."

Así que el aviso se arregla añadiendo una línea, y la función falla después en el primer nombre de tabla sin esquema:

create or replace function public.count_invoices()
returns bigint
language plpgsql
set search_path = ''
as $$
begin
  return (select count(*) from invoices);  -- relation "invoices" does not exist
end;
$$;

Escribe from public.invoices y vuelve a funcionar. Mantén el ajuste: está para que quien llame a la función no pueda apuntar un nombre sin esquema a una tabla suya.

Llamada desde tu app con .rpc(), el error atraviesa PostgREST como 42P01 con HTTP 404, el estado que el código de PostgREST da a una tabla desconocida. Un trigger de nuevos registros que falla así aparece como "Database error saving new user".

Con la ruta de búsqueda vacía no hay ninguna planta que recorrer, así que solo una dirección completa encuentra la sala.

Causa 4: la tabla todavía no está en ese proyecto

La tabla existe en otra base de datos, o solo en tu código.

Una cuenta de Supabase puede tener varios proyectos, y una rama es un proyecto aparte con su propia base de datos. Una tabla creada en el SQL Editor del proyecto equivocado es una sala en otro edificio. La versión de este error para columnas muestra cómo comparar la referencia del proyecto en la dirección de Supabase de tu app con la del dashboard.

Si construyes con Lovable, repasa el chat buscando SQL que te pidieran aprobar. La documentación de Supabase de Lovable dice que "writes the SQL, shows it to you, and asks for your approval in the project chat before running it". Hasta que lo apruebas, la tabla solo existe en el código que la lee.

Mover una tabla de la que depende tu app

Guarda una copia de la base de datos de antes de moverla, y entérate cuando cambie algo de lo que tu app expone.

Reeve Care guarda una copia de tu base de datos de Supabase:

  • una copia cifrada según un calendario, en el almacenamiento propio de Reeve en la UE, fuera de tu cuenta de Supabase
  • cada esquema que creaste, public y los demás, con sus tablas y filas tal como estaban antes de tu cambio
  • cada copia verificada antes de contar, contando las filas de cada tabla
  • también tus archivos subidos, en cuanto conectas una credencial de Storage
  • una restauración con un clic, con una copia de seguridad del estado actual tomada antes
  • todo lo que hace Monitor

Reeve Monitor sigue comprobando desde fuera:

  • si tu app está en línea, comprobado cada minuto
  • la comprobación de seguridad completa cada hora, en hasta tres apps
  • un mensaje cuando un resultado cambia, para que un despliegue que cambia lo que tu app expone te llegue sin que tengas que ir a mirar
  • un informe mensual, en lenguaje claro, de lo que vio

Qué hacer ahora

Qué hacer

  • Lee el mensaje: el nombre entre comillas es lo que se pidió, y public. delante es el esquema que se recorrió.
  • Compara el nombre con el Table Editor, mayúsculas incluidas. Una tabla con una mayúscula en el nombre necesita comillas dobles en SQL.
  • Averigua en qué esquema está la tabla con la consulta a information_schema.tables, y llama a una tabla fuera de public con .schema('nombre').from(...).
  • Antes de exponer un esquema, ejecuta la consulta a pg_tables y activa la Row Level Security en cada tabla que diga false.
  • Dentro de una función con search_path = '', escribe cada tabla como public.nombre.
  • Comprueba que el proyecto al que llama tu app es aquel en el que creaste la tabla.

Empieza por los dos nombres del mensaje, la tabla y el esquema que va delante, porque dicen exactamente qué puerta se pidió y en qué planta. Antes de mover, renombrar o exponer algo que la gente usa, guarda una copia de la base de datos de antes del cambio.

Preguntas frecuentes

¿Por qué Supabase dice que mi tabla no existe?

Porque en el esquema que recorrió la consulta no hay ninguna tabla con ese nombre exacto. Comprueba cuatro cosas en orden: las mayúsculas del nombre, el esquema donde está la tabla (tu app busca en public salvo que le digas otra cosa), el proyecto con el que habla tu app, y si la consulta se ejecuta dentro de una función con el search_path vacío, donde cada tabla necesita su esquema delante.

¿Qué significa "the schema must be one of the following"?

Es el error PGRST106 con la redacción de las versiones antiguas de PostgREST. Tu app pidió un esquema que el proyecto no expone, por ejemplo con .schema('nombre'), y la lista que sigue son los esquemas expuestos. Las versiones nuevas dicen "Invalid schema: nombre" y ponen la misma lista en la pista. Exponer el esquema lo arregla, y conviene decidirlo a conciencia.

¿Es seguro exponer un esquema en Supabase?

Solo cuando cada tabla que contiene tiene la Row Level Security activada y una regla escrita. La guía de Supabase sobre esquemas propios da a anon, el rol de cualquier visitante que no ha iniciado sesión, acceso completo a cada tabla del esquema, incluidas las que se creen después. Su guía de seguridad añade que las tablas creadas en el SQL Editor no tienen Row Level Security por defecto.

¿Importan las mayúsculas en el nombre de una tabla de Supabase?

Sí. Un nombre escrito en SQL sin comillas dobles se pasa a minúsculas, mientras que el Table Editor y supabase-js conservan las mayúsculas tal como se escribieron. Una tabla creada como Invoices se escribe "Invoices", con comillas, en SQL, y .from('Invoices') en tu app.

¿Debería mover la tabla a public?

Moverla arregla la mitad del error que tiene que ver con el esquema y nada de la seguridad: una tabla que tu app lee desde el navegador necesita un permiso para el rol que la lee y Row Level Security con una regla, esté donde esté. Una tabla que solo necesita el código de servidor puede quedarse en un esquema no expuesto, al que se llega con una función en public que hace la única tarea que tu app necesita.

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.