Saltar al contenido

Fundamentos de seguridad

Supabase "duplicate key value violates unique constraint", explicado

Supabase "duplicate key value violates unique constraint": el valor ya está ocupado. Encuentra la columna, elige un arreglo y aparta el valor de la pantalla.

Vlad Tkachenko13 min de lectura
Una lista de nombres en una tablilla, una línea en turquesa, y delante un papel con el mismo nombre en ámbar que se queda fuera.

En resumen

  • Supabase "duplicate key value violates unique constraint" (código de Postgres 23505, HTTP 409 en tu app) significa que una fila ya tiene ese valor en una columna que admite cada valor una sola vez. El nombre entre comillas dice qué columna.
  • "Key (id)=(1) already exists" cuando tu app no envía ningún id significa que el contador de id de la tabla va por detrás de sus filas. Una línea con setval lo pone en su sitio.
  • Upsert solo ayuda si onConflict nombra esa columna, y desde el navegador también necesita las reglas de lectura de la tabla. Enséñales a tus usuarios una frase tuya: el mensaje en bruto puede devolver el valor.

Alguien rellena el formulario de tu lista de espera, espera y vuelve a pulsar el botón. La primera pulsación ya había entrado. La segunda vuelve como error, y la consola del navegador lo muestra entero:

{
  "code": "23505",
  "details": "Key (email)=(maria@example.com) already exists.",
  "hint": null,
  "message": "duplicate key value violates unique constraint \"waitlist_email_key\""
}

Supabase "duplicate key value violates unique constraint" significa que Postgres se negó a añadir la fila porque otra fila ya tiene el mismo valor en una columna que admite cada valor una sola vez. Aquí la columna es email, y Maria ya está en tu lista.

El consejo que circula para este error es cambiar el insert por un upsert. A veces es lo correcto. Pero en una tabla donde tus visitantes solo pueden añadir, un upsert desde el navegador necesita reglas que les dejen leer la tabla, y la línea con la dirección de Maria es algo que tu app no debería mostrar nunca a un visitante.

¿Qué significa "duplicate key value violates unique constraint"?

Significa que el valor que querías guardar ya está en una columna que admite cada valor una vez, así que Postgres rechazó la fila entera. No se escribió nada, y la fila que estaba primero sigue igual.

Piensa en la tabla como la lista de invitados de una puerta, una línea por nombre. Una restricción única es la regla del portero: un nombre entra en la lista una sola vez. Tu app llegó con un nombre que ya estaba escrito, y él la rechazó.

Cada parte del error dice una cosa:

CampoEjemploQué te dice
code23505El código de Postgres para este error, llamado unique_violation en su lista de códigos
messageduplicate key value violates unique constraint "waitlist_email_key"Qué regla rechazó la fila, por su nombre
detailsKey (email)=(maria@example.com) already exists.La columna y el valor que chocaron, cuando Postgres decide que quien llama puede verlos
hintnullNada que añadir para este error
Estado HTTP409 ConflictCómo informa de 23505 PostgREST, la parte de Supabase que convierte las llamadas supabase.from(...) en SQL (su código)

El texto es del propio Postgres. El código que lanza el error solo añade la línea Key ... already exists cuando tiene una descripción de la clave que dar, y una sección más abajo explica cuándo no la tiene.

¿Qué columna causó el error?

Te lo dice el nombre entre comillas, porque Postgres lo construye a partir de la tabla y las columnas, salvo que alguien haya elegido otro. Su código de nombres da tres formas:

El nombre acaba enEsEjemplo
_keyuna restricción única sobre las columnas entre el nombre de la tabla y _keywaitlist_email_key: tabla waitlist, columna email
_pkeyla clave primaria de la tabla, normalmente idorders_pkey
_idx, u otra cosaun índice único creado a mano, o una restricción a la que alguien puso nombreprofiles_username_idx

El mensaje dice "unique constraint" en los tres casos. Para ver exactamente qué cubre un nombre, ejecuta esto en el SQL Editor con el nombre de tu mensaje:

select tablename, indexdef
from pg_indexes
where indexname = 'waitlist_email_key';

indexdef vuelve con algo como CREATE UNIQUE INDEX waitlist_email_key ON public.waitlist USING btree (email), y las columnas entre paréntesis son las que no pueden repetirse. Una regla sobre dos columnas, como (team_id, email), deja que la misma dirección entre en dos equipos y la rechaza la segunda vez en el mismo.

"Key (id)=(1) already exists" cuando nunca enviaste un id

Este significa que el contador de id de la tabla va por detrás de las filas que ya tiene, así que repartió un número ocupado. Tu app no envió ningún id. Postgres tomó el siguiente número del contador, y ya había una fila con ese número.

En la lista de invitados, el portero también reparte pulseras numeradas de un rollo. Alguien copió a mano a los doscientos primeros invitados, números de pulsera incluidos, y nadie hizo avanzar el rollo. Sigue empezando en 1.

Una columna que se numera sola toma cada número de una secuencia, que es como Postgres llama a ese contador. La página de Postgres sobre columnas de identidad dice que una columna así "does not guarantee uniqueness", y da este caso como una de las razones: hay valores que "could be inserted manually into the identity column". Llegan filas con el número ya puesto desde una importación de CSV que incluye la columna id, una copia desde otro proyecto o un script de seed. El contador no se entera de ninguna. La página de solución de problemas de Supabase sobre secuencias de id trata exactamente este mensaje en un _pkey, y su arreglo también es un setval.

El arreglo es una sola instrucción. Pon tu tabla y tu columna en lugar de orders e id; la tabla es la parte del nombre antes de _pkey:

select setval(
  pg_get_serial_sequence('public.orders', 'id'),
  (select max(id) from public.orders)
);

pg_get_serial_sequence encuentra el contador detrás de la columna, y la documentación de Postgres dice que sirve para los dos tipos: "If the column is an identity column, the associated sequence is the sequence internally created for that column." Después, setval con dos argumentos ajusta el contador para que "the next nextval will advance the sequence before returning a value". La siguiente fila nueva recibe el id más alto más uno. Si Postgres responde que la relación no existe, la tabla se escribe de otra forma, lleva una mayúscula o está en otro esquema, y ese error tiene su propio artículo.

Una restauración desde la salida de pg_dump no te trae hasta aquí. Su documentación enumera lo que lleva un volcado de datos: "Table data, large objects, and sequence values are dumped." Cada contador vuelve con sus filas.

Las filas copiadas con sus propios id dejan el contador donde estaba. setval lo lleva más allá del id más alto, y la siguiente fila recibe un número libre.

Las tres formas de tratar un duplicado

Decide qué debe pasar cuando el valor ya está: rechazar la fila nueva y decirlo, dejar que sustituya a la antigua o dejar de enviarla.

Recházala, con tus palabras. Encaja cuando una segunda fila sería un error: una dirección ya registrada, un nombre de usuario ya ocupado. El insert falla, tu app lee el código y la persona ve una frase que escribiste tú:

const { error } = await supabase.from('profiles').insert({ id: user.id, username })

if (error?.code === '23505') {
  setMessage('Ese nombre de usuario ya está ocupado. Prueba con otro.')
} else if (error) {
  setMessage('Algo ha fallado. Vuelve a intentarlo.')
}

Deja que sustituya a la antigua. Encaja cuando debe ganar la versión más nueva: una fila de ajustes por usuario, un carrito por sesión. Eso es un upsert, y la sección siguiente trata de cuándo sale mal.

Deja de enviarla. A veces la segunda fila nunca debió existir, y llega desde uno de estos sitios:

  • un botón que vuelve a enviar el formulario cuando se pulsa otra vez. Desactívalo hasta que responda la primera petición.
  • un reintento tras una respuesta lenta, cuando el primer intento sí había entrado
  • dos escritores para una misma fila, como un trigger que crea el perfil de cada usuario nuevo y código de la app que inserta ese mismo perfil después del registro

El caso del trigger conviene reconocerlo a la primera. Un trigger que copia los usuarios nuevos en una tabla tuya falla con este error cuando el valor que copia ya está ahí, y Supabase informa de todo el registro como "Database error saving new user". El 23505 en sí está en los logs de Postgres, y ese artículo muestra dónde.

¿Deberías usar upsert?

Úsalo cuando la fila nueva deba sustituir a la antigua, y comprueba tres cosas antes de confiar en él.

.upsert() en supabase-js es un insert que hace otra cosa cuando la fila existe. Su código fuente lo dice en dos líneas: "If true, duplicate rows are ignored. If false, duplicate rows are merged with existing rows." Es la opción ignoreDuplicates, y Postgres ejecuta las dos versiones como un insert ... on conflict.

1. onConflict tiene que nombrar la columna de tu error.La documentación de PostgREST dice: "By default, upsert operates based on the primary key columns". Un upsert de { email } en una tabla cuya clave primaria es id busca un choque en id, no lo encuentra porque el id es nuevo, inserta y se topa con la regla de email, con el mismo 23505. La referencia de INSERT de Postgres dice que la acción alternativa se aplica "if an arbiter constraint or index specified by conflict_target is violated". Un choque en cualquier otra columna rechaza la fila como antes.

await supabase
  .from('settings')
  .upsert({ user_id: user.id, theme: 'dark' }, { onConflict: 'user_id' })

2. Esa columna necesita una regla única propia. Sin ella, Postgres no tiene con qué comparar y responde there is no unique or exclusion constraint matching the ON CONFLICT specification, código 42P10 (la línea que lo lanza).

3. Desde el navegador, la Row Level Security lee más reglas tuyas. La Row Level Security es el conjunto de reglas por fila que Supabase comprueba en cada petición hecha con tu clave pública. Para un upsert, la referencia de Postgres sobre policies dice: "SELECT permissions are required on the relation, and the rows proposed for insertion are checked using the relation's SELECT policies." Cuando la fila existe y el upsert fusiona, la fila existente también se comprueba contra las reglas de actualización de la tabla. Una fila que no pasa alguna de esas comprobaciones termina la petición con new row violates row-level security policy.

Así que en la lista de espera, un upsert desde el navegador falla mientras la tabla no tenga una regla de lectura que un visitante cumpla. Una regla de lectura que un desconocido cumple para cualquier dirección es una regla que deja a los desconocidos leer tu lista. En la puerta, reescribir una línea significa que antes te enseñaron la lista. Para una tabla así, mejor rechaza el duplicado, o usa la función de la sección siguiente.

Desde el navegador, un upsert se comprueba contra las reglas de lectura y de actualización de la tabla además de su regla de inserción. En una tabla donde los visitantes solo pueden añadir, la regla de lectura es la que lo detiene.

¿Es seguro mostrar este error a tus usuarios?

No. Muestra una frase tuya y guarda el error en bruto en tus logs, por dos razones: la línea de detalle puede devolver el valor, y el error confirma que el valor existe muestres lo que muestres.

La línea de detalle repite el valor. Key (email)=(maria@example.com) no hace daño en la pantalla de Maria, y es otra cosa en una captura publicada en un foro de soporte, en una herramienta de seguimiento de errores o en la pantalla de alguien que escribió la dirección de otra persona en tu formulario. Postgres ya la protege. La función que construye la línea (BuildIndexValueDescription) comprueba primero: "if RLS is enabled for the relation. If so, return NULL to avoid leaking data." Una petición hecha con tu clave pública a una tabla con Row Level Security activada recibe "details": null.

Leída al revés, la línea te dice algo de la tabla. Si la consola de tu navegador muestra Key (...)=(...) para una petición que tu app hizo con la clave pública, la Row Level Security está desactivada en esa tabla. La única excepción es una petición que tu app envía a través de código de servidor con la clave secreta, que se salta todas las reglas. null demuestra menos: Postgres también quita la línea cuando quien llama no puede leer todas las columnas de la clave. Activar Row Level Security en cada tabla cubre el interruptor y la primera regla.

Una tabla con las reglas desactivadas le responde a un desconocido igual que a ti. Nuestro escaneo gratuito lee tu app en vivo con la misma clave pública que recibe un visitante, informa de qué tablas devuelven filas y no necesita cuenta.

El error responde a una pregunta que quizá no quieras responder. Aun sin la línea de detalle, un 409 le dice a quien envió la fila que el valor ya está. La página de Postgres sobre seguridad de filas dice que las restricciones únicas "always bypass row security to ensure that data integrity is maintained", y te pide diseñar tablas y reglas para que esas comprobaciones no se conviertan en un canal encubierto. En la lista de espera, cualquiera puede saber si una dirección está en tu lista intentando añadirla. El portero nunca lee el nombre en voz alta, y cuando rechaza a alguien, la cola sabe igualmente que el nombre estaba ahí.

Si eso importa para tu lista, dale al navegador la misma respuesta en los dos casos. Una función en la base de datos puede añadir la dirección, saltársela si ya está y no devolver nada en ninguno de los dos casos:

create function public.join_waitlist(new_email text)
returns void
language sql
security definer
set search_path = ''
as $$
  insert into public.waitlist (email)
  values (new_email)
  on conflict (email) do nothing;
$$;

Tu app llama a supabase.rpc('join_waitlist', { new_email: email }). security definer ejecuta el insert como el dueño de la función, así que los visitantes no necesitan ninguna regla sobre la tabla, y la regla que les dejaba insertar puede irse, o el camino viejo sigue abierto junto al nuevo. Lo que "Database error saving new user" cuenta de security definer explica lo que cede, y llamar a una función de Supabase muestra cómo hacer coincidir los nombres de los argumentos.

Postgres escribe el valor en la línea de detalle solo para quien llama sin que le aplique la Row Level Security. Verlo desde el navegador significa que las reglas de la tabla están desactivadas.

Antes de cambiar cómo escribe tu app

Guarda una copia de la base de datos de antes de pasar un insert a upsert o de reajustar un contador, y entérate cuando una tabla empiece a responder a desconocidos.

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 tabla con sus filas tal como estaban antes de que una fusión las sobrescribiera
  • el contador de id de cada tabla vuelve con sus filas al restaurar, para que la siguiente fila nueva reciba un número libre
  • 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 una tabla que empieza a responder a desconocidos tras un despliegue 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 nombre entre comillas. La tabla y la columna están antes de _key, y _pkey significa la clave primaria.
  • Si el nombre acaba en _pkey y tu app no envía id, ajusta el contador con la instrucción setval de arriba.
  • Captura 23505 en tu app y muestra una frase que hayas escrito tú, nunca el mensaje en bruto.
  • Usa upsert solo donde la fila nueva deba sustituir a la antigua, con onConflict en la columna del error.
  • Si la consola del navegador muestra una línea Key (...)=(...) para una petición hecha con tu clave pública, activa la Row Level Security en esa tabla.

Empieza por el nombre entre comillas, porque dice qué regla rechazó la fila y qué columna vigila esa regla. Antes de ejecutar un setval, un upsert o cualquier otra cosa que reescriba filas de las que dependen personas, guarda una copia de la base de datos de antes del cambio.

Preguntas frecuentes

¿Qué significa duplicate key value violates unique constraint?

Significa que Postgres se negó a añadir o cambiar una fila porque otra fila ya tiene el mismo valor en una columna, o grupo de columnas, que admite cada valor una sola vez. No se escribió nada. El código es 23505, y desde una app de Supabase llega como HTTP 409.

¿Qué columna causó el error?

Te lo dice el nombre de la regla entre comillas. Postgres nombra una restricción única según su tabla y sus columnas, con _key al final, así que waitlist_email_key es la columna email de la tabla waitlist, y un nombre que acaba en _pkey es la clave primaria, normalmente id. La línea de detalle, cuando aparece, nombra la columna y el valor.

¿Debería usar upsert?

Solo cuando la fila nueva deba sustituir a la antigua, como una fila de ajustes por usuario. Pon onConflict en la columna del error, porque los upserts de Supabase comprueban la clave primaria por defecto. Desde el navegador, un upsert necesita además reglas de lectura que la fila cumpla, así que en una tabla donde los visitantes solo pueden añadir falla o te obliga a abrir la tabla para lectura.

¿Por qué pasa esto al registrarse?

Hay dos caminos: un trigger copia cada usuario nuevo en una tabla tuya y el valor que copia ya está ahí, o tu app inserta la misma fila de perfil que el trigger ya creó. Supabase informa de ambos como "Database error saving new user", y el 23505 en sí está en los logs de Postgres.

¿Es seguro mostrar este error a los usuarios?

No. La línea de detalle puede repetir el valor, el mensaje nombra tu tabla y tu columna, y el propio error le dice a quien envió la fila que el valor existe. Captura el código 23505, muestra una frase que hayas escrito tú y guarda el error en bruto en tus propios logs.

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.