Prisma update con undefined no borra el campo: usa null

Editas un registro, borras el contenido de un campo opcional (o quitas una relación opcional), pulsas "Guardar" y la interfaz confirma que todo salió bien. Al recargar, el valor anterior sigue ahí. No hay ningún error en el servidor. Y si intentas enviar null para forzar el borrado, el esquema de validación lo rechaza:

Invalid input: expected number, received null

(En Zod 3 el texto es Expected number, received null.) Este artículo explica por qué Prisma ignora el cambio, cómo corregir el esquema de Zod para actualizaciones parciales (PATCH) y qué debe enviar el formulario para que "vaciar" un campo funcione.

Por qué pasa

La documentación de Prisma Client fija dos reglas: null is a value (null es un valor) y undefined means do nothing (undefined significa no hacer nada). En un update, una propiedad con undefined se trata como si no estuviera, y solo null escribe NULL en la base de datos. Con este modelo de ejemplo:

model Categoria {
  id        Int        @id @default(autoincrement())
  nombre    String
  productos Producto[]
}

model Producto {
  id          Int        @id @default(autoincrement())
  nombre      String
  descripcion String?
  categoriaId Int?
  categoria   Categoria? @relation(fields: [categoriaId], references: [id])
}

Partiendo de un producto con descripción y categoría, esto es lo que dejó cada llamada (Prisma 6.19, SQLite):

update({ data: { descripcion: undefined, categoriaId: undefined } })  // sin cambios
update({ data: { descripcion: null } })                                // descripcion = NULL
update({ data: { categoriaId: null } })                                // categoriaId = NULL

Ahora, ¿cómo llega undefined al update si el usuario solo vació el campo? Hay tres eslabones que fallan a la vez:

  1. Una transformación de Zod convierte "" en undefined. Es un patrón muy común para "limpiar" formularios:
    const textoOpcional = z.string().optional().transform((v) => v?.trim() || undefined);
    // parse({ descripcion: '' })  ->  { descripcion: undefined }
    Un campo vacío y un campo ausente quedan igual, y Prisma interpreta ambos como "no tocar".
  2. El esquema de actualización no admite null. Con z.number().int().optional(), enviar null da el error de arriba. El formulario no tenía forma legal de pedir "ninguno".
  3. El selector (combobox) no ofrecía "sin valor". Al quitar la selección enviaba undefined, y JSON.stringify descarta las propiedades con undefined:
    JSON.stringify({ a: undefined, b: null })  // '{"b":null}'
    La propiedad ni siquiera viaja en la petición.

Solución

  1. En el esquema, convierte el texto vacío en null y admite null y ausencia.

    import { z } from 'zod';
    
    const textoLimpiable = z
      .string()
      .trim()
      .transform((v) => (v === '' ? null : v))
      .nullable()
      .optional();
    
    export const actualizarProductoSchema = z.object({
      nombre: z.string().trim().min(1).optional(),   // obligatorio en BD: nunca null
      descripcion: textoLimpiable,                   // se puede vaciar
      categoriaId: z.number().int().positive().nullable().optional(),
    });

    Así se conservan los tres estados: clave ausente (no tocar), null (borrar) y valor (guardar). En la prueba, parse({}) devolvió {} sin ninguna clave, parse({ descripcion: '' }) y parse({ descripcion: ' ' }) devolvieron { descripcion: null }, y parse({ categoriaId: null }) devolvió { categoriaId: null }, tanto con Zod 4.6 como con Zod 3.25.

  2. Pasa el resultado a Prisma tal cual. No "limpies" los null antes del update:

    export function actualizarProducto(id: number, body: unknown) {
      const data = actualizarProductoSchema.parse(body);
      return prisma.producto.update({ where: { id }, data });
    }

    Las claves ausentes no se tocan y las que valen null se borran. Evita patrones como valor != null ? valor : undefined: la propia documentación de Prisma lo muestra como forma de ignorar un null, que es justo lo contrario de lo que necesitas aquí.

  3. En la interfaz, envía null de forma explícita y ofrece una opción para quitar el valor:

    <select
      value={form.categoriaId ?? ''}
      onChange={(e) =>
        setForm({ ...form, categoriaId: e.target.value === '' ? null : Number(e.target.value) })
      }
    >
      <option value="">Quitar categoría</option>
      {categorias.map((c) => (
        <option key={c.id} value={c.id}>{c.nombre}</option>
      ))}
    </select>

    Para valores de texto basta con valor || null. Con identificadores numéricos usa la comparación con '' como arriba, porque 0 también es falso.

  4. Para relaciones opcionales, elige un estilo y no los mezcles. Con la clave foránea escalar:

    prisma.producto.update({ where: { id }, data: { categoriaId: null } });

    O con la relación:

    prisma.producto.update({ where: { id }, data: { categoria: { disconnect: true } } });

    Ambas dejaron categoriaId en NULL en la prueba. La documentación indica que disconnect solo está disponible si la relación es opcional. Si pones categoriaId y categoria en el mismo data, Prisma lanza un PrismaClientValidationError con un mensaje como Unknown argument `categoriaId`. Did you mean `categoria`?.

Cómo verificar que quedó resuelto

Primero, prueba el esquema de forma aislada, sin base de datos:

expect(actualizarProductoSchema.parse({})).toEqual({});
expect('descripcion' in actualizarProductoSchema.parse({})).toBe(false);
expect(actualizarProductoSchema.parse({ descripcion: '' })).toEqual({ descripcion: null });
expect(actualizarProductoSchema.parse({ categoriaId: null })).toEqual({ categoriaId: null });

Después, comprueba el recorrido completo contra una base de datos de desarrollo: edita un registro, vacía la descripción y quita la categoría, guarda, recarga la página y consulta la fila (con Prisma Studio o una consulta SQL). Las dos columnas deben estar en NULL. En el recorrido de prueba con el cliente de Prisma, un PATCH con solo nombre dejó la descripción y la categoría intactas; uno con descripcion: '' dejó la descripción en NULL, y uno con categoriaId: null dejó la categoría en NULL.

Variantes y errores relacionados

Campos numéricos con z.coerce.number()

Un campo numérico vacío no se comporta como el texto: z.coerce.number().safeParse('') devolvió éxito con el valor 0. Vaciar el campo guardaría un cero. Convierte la cadena vacía antes:

const numeroLimpiable = z.preprocess(
  (v) => (v === '' ? null : v),
  z.coerce.number().nullable().optional(),
);

Campos obligatorios

No agregues .nullable() a las columnas que en la base de datos son obligatorias: el esquema debe rechazar el borrado antes de llegar a Prisma, con un error de validación claro.

Columnas Json

En campos Json, null es ambiguo (¿el valor JSON null o NULL de la base de datos?). Prisma ofrece Prisma.JsonNull y Prisma.DbNull para distinguirlos; revisa la documentación de campos JSON antes de usar null directamente.

Reutiliza la definición en creación y actualización

Si el esquema de creación y el de actualización definen el mismo campo de forma distinta (uno con .optional() y otro con .nullable().optional()), es fácil que vuelva a aparecer la diferencia entre vaciar y no tocar. Define los campos limpiables una sola vez (por ejemplo, textoLimpiable) y reutilízalos en ambos esquemas.

Resumen

  • En Prisma, undefined significa "no tocar el campo" y null significa "escribir NULL".
  • Una transformación como v?.trim() || undefined convierte "vaciar" en "no hacer nada" sin dar error.
  • Para campos limpiables usa .nullable().optional() y transforma "" en null; deja que las claves ausentes sigan ausentes.
  • El formulario debe enviar null de forma explícita (JSON.stringify elimina undefined) y ofrecer una opción "Quitar".
  • Para relaciones opcionales usa categoriaId: null o categoria: { disconnect: true }, nunca ambos en la misma operación.

Referencias

Comentarios