Saltar al contenido

Fundamentos de seguridad

Error de Supabase "null value in column violates not-null constraint"

Supabase "null value in column violates not-null constraint": una columna obligatoria llegó vacía. De dónde vino el null, y cómo arreglarlo.

Vlad Tkachenko14 min de lectura
Un formulario de papel delante de un cajón de archivador abierto, con su segunda casilla con asterisco vacía y bordeada en ámbar.

En resumen

  • Supabase "null value in column violates not-null constraint" (código de Postgres 23502, HTTP 400 en tu app) significa que una fila llegó sin nada en una columna que siempre debe tener un valor. El mensaje nombra la columna y la tabla. No se guardó nada.
  • El valor vacío viene de un campo que tu app nunca envía, de un null que tu código envía a propósito o de una columna sin el valor por defecto que dabas por hecho. Un upsert y una inserción múltiple pueden producirlo en una fila que parece completa.
  • Mantén la regla y arregla la fila. Desde código de servidor, nunca devuelvas el error en bruto al navegador: su línea "Failing row contains" puede mostrar la fila entera.

Pulsas Guardar en una tarea nueva de tu app y no se guarda nada. La página no muestra ningún error y la lista sigue igual. La consola del navegador tiene el motivo:

{
  "code": "23502",
  "details": null,
  "hint": null,
  "message": "null value in column \"category_id\" of relation \"tasks\" violates not-null constraint"
}

Supabase "null value in column violates not-null constraint" significa que una fila llegó a tu base de datos sin nada en una columna que siempre debe tener un valor, y Postgres rechazó la fila entera. Aquí la columna es category_id en tu tabla tasks. Al guardar no se envió ninguna categoría.

El arreglo más rápido es quitar la regla: una línea de SQL y la columna puede quedarse vacía. El error desaparece, y a partir de ahí llegan filas con esa casilla en blanco, que el resto de tu app encuentra donde no estabas mirando. Averigua primero de dónde salió el valor vacío. Casi siempre es uno de unos pocos sitios.

¿Qué significa "null value in column violates not-null constraint"?

Significa que una columna de la fila que tu app quiso guardar estaba vacía, que esa columna nunca puede estarlo, y que Postgres se negó a guardar nada de la fila. No se escribió nada. Null es la palabra de Postgres para la ausencia de valor, que no es lo mismo que un cero ni que un texto sin letras.

Piensa en cada fila como un formulario de papel y en la tabla como el cajón del archivador donde se guarda. Una restricción not-null es un asterisco impreso junto a una casilla. Postgres está junto al cajón y no archiva ningún formulario que tenga vacía una casilla con asterisco. El formulario vuelve, y el mensaje dice qué casilla.

CampoEjemploQué te dice
code23502El código de Postgres para este error, llamado not_null_violation en su lista de códigos
messagenull value in column "category_id" of relation "tasks" violates not-null constraintLa columna vacía y luego la tabla. "Relation" es la palabra de Postgres para una tabla
detailsFailing row contains (…), o nullUna copia de la fila rechazada, cuando Postgres decide que quien llama puede verla. Una sección más abajo explica cuándo
hintnullNada que añadir para este error
Estado HTTP400 Bad RequestPostgREST, la parte de Supabase que convierte las llamadas supabase.from(...) en SQL, no tiene un caso propio para 23502, así que su código cae en 400

El texto es el de Postgres, de la función que lanza el error, y se lee igual tanto si la fila vino de tu app como de un trigger o del SQL Editor.

¿De dónde salió el valor vacío?

Normalmente de uno de tres sitios en el código de tu propia app, y la columna del mensaje te dice dónde mirar primero. Otros dos empiezan dentro de la base de datos.

Un campo que tu app nunca envía. Es el caso más común. La columna pasó a ser obligatoria después de crear el formulario: un constructor con IA añadió category_id a la tabla cuando pediste categorías, la marcó como obligatoria y dejó como estaba el formulario que guarda las tareas. Nada en la petición nombra la columna, la columna no tiene valor por defecto, así que queda vacía. Compara las columnas de tu Table Editor con el objeto que tu app pasa a .insert(). La que falta es la del mensaje.

Una clave escrita distinto que su columna ni siquiera llega hasta aquí. PostgREST rechaza categoryId enviado a una columna llamada category_id antes de que Postgres vea la fila, y ese error tiene su propio artículo.

Un null que tu código envía a propósito. { category_id: selected?.id ?? null } envía un null explícito cuando no hay nada seleccionado, y un null explícito es un valor para la petición. El undefined de JavaScript se comporta de otra forma. JSON no tiene ese valor, así que JSON.stringify lo deja "omitted (when found in an object)", y una clave con undefined nunca llega a Supabase. Cuál de los dos produce tu código decide si un valor por defecto puede entrar en juego, y de eso trata la siguiente sección.

Una columna sin el valor por defecto que dabas por hecho. created_at suele rellenarse sola, porque la tabla dice default now(). user_id normalmente no, porque nada en Postgres sabe quién ha iniciado sesión hasta que le dices a la columna cómo averiguarlo. Una sección más adelante muestra cómo.

Un registro, bajo otro mensaje. Un trigger que copia cada usuario nuevo en una tabla profiles lee sus datos de registro con ->>, y los operadores JSON de Postgres "return NULL, rather than failing, if the JSON input does not have the right structure to match the request; for example if no such key or array element exists". Un formulario de registro que nunca envió full_name le da al trigger un nombre vacío, una columna obligatoria full_name lo rechaza, y Supabase informa de todo el registro como "Database error saving new user". El 23502 en sí está en los registros de Postgres, y ese artículo muestra dónde.

Un borrado en otra parte. Una clave foránea con on delete set null vacía la columna en cada fila que apuntaba a la borrada. Si esa columna también es obligatoria, lo que falla con este mensaje es el borrado, y el artículo sobre borrar usuarios explica cómo elegir una regla que encaje.

"Pero la columna tiene un valor por defecto"

Entonces la petición nombró la columna y la envió vacía. Postgres aplica un valor por defecto solo cuando una columna queda fuera de la fila por completo. Su documentación sobre valores por defecto dice: "When a new row is created and no values are specified for some of the columns, those columns will be filled with their respective default values." Un null explícito cuenta como valor indicado. En el formulario, una casilla con un valor preimpreso cuenta como rellena hasta que alguien escribe "ninguno" encima de lo impreso.

supabase-js escribe "ninguno" encima en un sitio que no esperarías: una inserción múltiple, en la que pasas un array de filas. Nombra todas las columnas que aparecen en cualquiera de las filas, y su código fuente documenta la opción que decide qué pasa con los huecos, defaultToNull: "Make missing fields default to null. Otherwise, use the default value for the column. Only applies for bulk inserts." Vale true mientras no la cambies. La documentación de PostgREST dice lo mismo para una petición que enumera sus columnas, que es lo que supabase-js envía con un array: "any missing columns in the payload will be inserted as null value by default."

Así que aquí la segunda tarea entra con status vacío, aunque la tabla dé a status el valor por defecto 'todo', y un status obligatorio rechaza toda la inserción:

await supabase.from('tasks').insert([
  { title: 'Escribir el brief', status: 'doing' },
  { title: 'Enviar la factura' },
])

Cualquiera de los dos arreglos sirve. Envía status en cada fila, o dile a supabase-js que deje en pie los valores por defecto de la tabla:

await supabase.from('tasks').insert(rows, { defaultToNull: false })

Esa opción añade la cabecera Prefer: missing=default que lee PostgREST. Una sola fila pasada como objeto, sin el array alrededor, no hace esto: una clave que falta en ella falta en la inserción, y se aplica el valor por defecto.

En una inserción múltiple, una columna que una fila tiene y otra no entra como null en la que no la tiene. defaultToNull: false deja que la rellene el valor por defecto de la tabla.

¿Por qué falla un upsert con esto si la fila ya existe?

Porque Postgres revisa que la fila nueva no tenga columnas obligatorias vacías antes de buscar la fila existente que actualizaría. Un upsert es un formulario marcado "archívalo, o si ya hay un expediente de esta persona, cámbialo". Postgres mira primero los asteriscos. Un formulario que solo trae una foto de perfil nueva vuelve, aunque el expediente que debía cambiar esté en el cajón.

El orden está escrito en el código que ejecuta una inserción: "Check the constraints of the tuple." va antes de "Perform a speculative insertion.", que es el paso que encuentra el choque al que responde una cláusula on conflict. Tuple es la palabra de Postgres para una fila. Así que este upsert, pensado para cambiar una columna de un perfil que existe, falla en cualquier otra columna que la tabla exija:

await supabase.from('profiles').upsert({ id: user.id, avatar_url: url })

Para cambiar algunas columnas de una fila que ya está, usa un update. Cae sobre la fila guardada, que tiene rellenas todas las columnas obligatorias, y cambia solo lo que envías:

await supabase.from('profiles').update({ avatar_url: url }).eq('id', user.id)

Deja el upsert para filas que quizá aún no existen, y envíalo con cada columna obligatoria. El artículo sobre claves duplicadas cubre el resto de lo que necesita un upsert: con qué columna compara y qué reglas de lectura pide desde el navegador.

La fila de un upsert se revisa en busca de columnas obligatorias vacías antes de que Postgres busque la fila que actualizaría. Un update parte de la fila guardada, que ya las tiene.

¿Deberías quitar la regla NOT NULL?

Solo si la columna nunca debió ser obligatoria. Un teléfono que tu formulario trata como opcional es ese caso, y ahí quitar la regla es el arreglo correcto:

alter table public.profiles alter column phone drop not null;

Para cualquier otra columna, mantén la regla y rellena la casilla. Sin ella, cada fila guardada después puede quedarse sin el valor que la regla garantizaba, y tu app se encuentra el hueco en otro sitio: una página que lee task.category.name se detiene con Cannot read properties of null, o un recuento de tareas por categoría pierde en silencio las que no tienen ninguna.

La columna donde quitarla sale más cara es user_id. Una fila guardada con el dueño vacío no se le muestra a nadie. Una regla de lectura escrita como auth.uid() = user_id nunca la incluye, porque en SQL un valor vacío no es igual a nada, ni siquiera a otro valor vacío. La tarea queda guardada, y desde entonces nadie la ve, tampoco la persona que la creó.

El arreglo para user_id es un valor por defecto que pone a quien haya iniciado sesión:

alter table public.tasks
  alter column user_id set default auth.uid();

Postgres evalúa un valor por defecto "whenever the default value is inserted (not when the table is created)", en sus propias palabras, así que cada fila nueva recibe el id de la persona que guarda. Sin nadie con sesión iniciada, auth.uid() devuelve null, como advierte la guía de Supabase sobre row level security, y el guardado sigue fallando. Es la regla haciendo su trabajo. El valor por defecto rellena la casilla solo cuando tu app la omite, así que lo que impide que alguien envíe el id de otra persona sigue siendo tu regla de inserción.

Esa regla de inserción también cambia el mensaje que ves. Postgres ejecuta las comprobaciones de Row Level Security de una tabla sobre una fila nueva antes que sus comprobaciones not-null: el mismo código de inserción pone "Check any RLS WITH CHECK policies." antes de "Check the constraints of the tuple.". Cuando tu regla de inserción compara user_id con auth.uid(), un user_id vacío falla primero en la regla y vuelve como new row violates row-level security policy. Un 23502 en user_id desde tu app significa una de tres cosas: la regla de inserción nunca mira user_id, las reglas están desactivadas en esa tabla, o la petición vino de código de servidor que se las salta.

¿Es seguro mostrar este error a tus usuarios?

No. Muestra una frase tuya y guarda el error en bruto en tus registros, porque la línea de detalle, cuando Postgres la incluye, es una copia de la fila entera.

La línea de detalle de una clave duplicada repite un valor. Esta repite el formulario. La función que la escribe muestra la fila como Failing row contains (…), con cada columna que quien llama puede leer. En un update, es la fila tal como se habría guardado, incluidas las columnas que nadie tocó. Un formulario que Postgres devuelve puede llevar una fotocopia de sí mismo grapada al rechazo, y la copia muestra todas las casillas.

Postgres decide quién recibe la fotocopia, y su código comprueba primero: "Check if RLS is enabled and should be active for the relation; if so, then don't return anything." Una petición hecha con tu clave pública a una tabla con Row Level Security activada recibe "details": null, que es lo que mostraba la consola al principio de esta página.

Dos llamadores reciben la copia completa.

Un navegador, cuando las reglas de la tabla están desactivadas. Si la consola muestra Failing row contains para una petición que tu app hizo con la clave pública, la Row Level Security está desactivada en esa tabla. Es la misma lectura que hace el artículo sobre claves duplicadas de su propia línea de detalle, y activar las reglas en todas las tablas explica el interruptor y la primera regla. Una tabla con las reglas desactivadas le da sus filas a un desconocido con la misma facilidad que a ti. Nuestro escaneo gratuito lee tu app en producción con la clave pública que recibe cualquier visitante y lista las tablas que devuelven filas. No necesita cuenta.

Código de servidor que tiene la clave secreta. La guía de Supabase dice que la clave secreta "authorizes access through the service_role Postgres role, which has the bypassrls attribute", así que las reglas nunca se aplican y la línea de detalle siempre viene rellena. Una Edge Function que actualiza una fila y responde con el error que recibió, return new Response(JSON.stringify(error)), le envía al navegador todas las columnas de la fila que estaba cambiando, sea de quien sea esa fila.

En el código de servidor, guarda el error y responde con tus propias palabras:

if (error) {
  console.error(error)
  return new Response(JSON.stringify({ message: 'No se pudo guardar.' }), {
    status: 400,
  })
}

En la app, captura el código y di qué puede hacer la persona:

const { error } = await supabase.from('tasks').insert(task)

if (error?.code === '23502') {
  setMessage('Elige una categoría antes de guardar.')
} else if (error) {
  setMessage('Algo salió mal. Inténtalo de nuevo.')
}

Mover una clave secreta a una Edge Function cubre la parte de la función, si la clave secreta sigue en tu app.

Con Row Level Security activada, el navegador no recibe copia de la fila. El código de servidor con la clave secreta la recibe entera, y la pasa si devuelve el error tal como llegó.

Antes de cambiar las reglas de una columna

Guarda una copia de la base de datos de antes de añadir un valor por defecto, quitar una regla o rellenar un valor en todas las filas, 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 fila tal como estaba antes de que un update sobre toda la tabla la reescribiera
  • 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 correo el día en que un nuevo escaneo califica tu app peor que el anterior, para que una tabla cuyas reglas se desactivaron 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 la columna y la tabla del mensaje. Esa es la casilla que llegó vacía.
  • Comprueba si tu app omite ese campo, lo envía como null o espera un valor por defecto que la columna no tiene.
  • Para cambiar algunas columnas de una fila que existe, usa .update() con un filtro. Un upsert tiene que llevar cada columna obligatoria.
  • En una inserción múltiple, envía cada columna en cada fila, o pasa { defaultToNull: false } para que se mantengan los valores por defecto de la tabla.
  • Mantén la regla en user_id y dale a la columna el valor por defecto auth.uid().
  • Desde código de servidor, registra el error en bruto y responde al navegador con una frase tuya.

Empieza por la columna del mensaje y luego por la línea de tu código que construye la fila. Si el arreglo cambia la propia tabla, un valor por defecto nuevo o un update sobre todas las filas, guarda una copia de la base de datos de antes de ejecutarlo.

Preguntas frecuentes

¿Qué significa null value in column violates not-null constraint?

Significa que tu app intentó guardar una fila sin valor en una columna que siempre debe tenerlo, y Postgres rechazó la fila entera. No se escribió nada. El mensaje nombra la columna y la tabla. El código es 23502, y desde una app de Supabase llega como HTTP 400.

¿Por qué la columna es null si tiene un valor por defecto?

Porque la petición nombró la columna y la envió vacía. Postgres aplica un valor por defecto solo cuando la columna se omite. supabase-js hace exactamente esto en una inserción múltiple: una columna que una fila tiene y otra no entra como null en la que no la tiene, salvo que pases defaultToNull: false.

¿Por qué mi upsert falla con un error not-null si la fila existe?

Postgres revisa que la fila nueva no tenga columnas obligatorias vacías antes de buscar la fila existente que actualizaría. Un upsert que lleva solo las columnas que quieres cambiar falla en cualquier otra columna obligatoria. Para una fila que ya existe, usa update con un filtro.

¿Debo quitar la restricción NOT NULL?

Solo si la columna nunca debió ser obligatoria, como un teléfono que tu formulario trata como opcional. Si no, el error se va y empiezan a llegar filas sin el valor, que tu app encuentra en otro sitio. Una fila con user_id vacío no se le muestra a nadie. Busca de dónde viene el null, o dale a la columna un valor por defecto como auth.uid().

¿Es seguro mostrar este error a los usuarios?

No. Cuando Postgres incluye la línea de detalle, es una copia de la fila entera. Una petición del navegador a una tabla con Row Level Security activada no recibe ninguna, pero el código de servidor con la clave secreta la recibe siempre. Captura el código 23502, muestra una frase que hayas escrito tú y guarda el error en bruto en tus registros.

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.