Más allá de ON DELETE CASCADE: patrones reales para no romper tu base de datos

Más allá de ON DELETE CASCADE: patrones reales para no romper tu base de datos

ON DELETE CASCADE es solo la punta del iceberg. Veamos patrones reales para no romper tu base de datos y migrar sin tirar el sistema.
05-10-2026 • 9 min de lectura • 0 visitas
Compartir:

Empezar una base de datos es fácil: cuatro tablas, un par de FOREIGN KEY y a correr. Lo complicado viene años después, cuando necesitas borrar, auditar o refactorizar… sin tirar producción.

En este post vamos a usar ON DELETE CASCADE como excusa para revisar patrones de diseño relacional que suelen explotar en sistemas reales: borrados en cascada, claves ajenas mal pensadas, soft deletes, auditoría, y migraciones en caliente.

Referencia recomendada para ampliar: ¿Por qué usar ON DELETE CASCADE en SQL te puede costar caro? Arquitectura de BD real.


1. El problema no es ON DELETE CASCADE, es el diseño

ON DELETE CASCADE no es malo en sí. El problema es usarlo como parche a un diseño difuso de responsabilidades y ciclos de vida.

Caso típico: modelo ingenuo de e‑commerce

CREATE TABLE customers (
  id          BIGSERIAL PRIMARY KEY,
  name        TEXT NOT NULL
);

CREATE TABLE orders (
  id          BIGSERIAL PRIMARY KEY,
  customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  total       NUMERIC(10,2) NOT NULL
);

CREATE TABLE order_items (
  id        BIGSERIAL PRIMARY KEY,
  order_id  BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product   TEXT NOT NULL,
  quantity  INT  NOT NULL
);

En el papel se ve bonito: borras un cliente y vuelan todos sus pedidos y líneas. En producción esto significa:

  • Pérdida irreversible de datos de facturación / auditoría.
  • Borrados masivos inesperados (un DELETE inocente dispara millones de filas).
  • Bloqueos y tiempos de respuesta disparados mientras la cascada se ejecuta.

El patrón de fondo: mezclar en la misma tabla entidades de negocio de largo plazo (pedidos) con la necesidad operativa de borrar otra entidad (cliente).

Regla práctica

Antes de meter un ON DELETE CASCADE, responde a estas preguntas:

  1. ¿Es aceptable perder esta información para siempre?
  2. ¿El volumen potencial de filas a borrar es acotado y controlable?
  3. ¿Estoy borrando algo que tiene implicaciones legales, contables o de auditoría?

Si dudas en alguna, probablemente no quieres cascada.


2. Patrones de borrado: duro, blando y archivo

En sistemas reales no hay un solo tipo de "borrado". Hay contextos.

2.1 Borrado duro (DELETE real)

Cuándo usarlo:

  • Datos puramente técnicos o de cacheo.
  • Entidades efímeras sin valor histórico (tokens de sesión, colas temporales).

Buenas prácticas:

  • Mantener el grafo de relaciones pequeño (tablas satélite, no críticas).
  • Añadir índices adecuados a las FKs para evitar escaneos completos en cascada.

2.2 Soft delete básico (deleted_at)

Patrón archiconocido: no borras, marcas.

ALTER TABLE customers ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE INDEX idx_customers_deleted_at ON customers(deleted_at);

Ventajas:

  • Recuperación rápida ante borrados erróneos.
  • Facilita auditoría lógica (saber "cuándo desapareció" algo).

Problemas si se aplica sin pensar:

  • FKs que siguen activas: otros registros siguen referenciando filas "borradas".
  • Consultas que olvidan filtrar WHERE deleted_at IS NULL.

Soluciones típicas:

  1. Vistas de sólo activos:

    CREATE VIEW active_customers AS
    SELECT * FROM customers WHERE deleted_at IS NULL;
    
  2. Políticas de Row Level Security (en Postgres) que oculten borrados:

    ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
    
    CREATE POLICY only_not_deleted ON customers
    USING (deleted_at IS NULL);
    

    (aplicando la política sólo a la app, no a tareas administrativas)

2.3 Archivo (tablas históricas)

Para datos de negocio que no deben desaparecer, pero sí pueden salir del camino crítico:

CREATE TABLE orders_archive (LIKE orders INCLUDING ALL);

-- Sin FKs de vuelta a customers
ALTER TABLE orders_archive DROP CONSTRAINT orders_archive_customer_id_fkey;

Estrategia de archivo:

  • Usar procesos batch o colas (no disparadores) para mover pedidos viejos.
  • Mantener índice por fecha para sacar el histórico rápido.

Beneficios principales:

  • La tabla "caliente" se mantiene pequeña y rápida.
  • No dependes de ON DELETE CASCADE para limpiar.

3. Claves ajenas: controlar el grafo o morir por él

Las FKs son tu red de seguridad… y también tu manera favorita de bloquear la base por minutos si el grafo es gigante.

3.1 Regla de oro: ciclo de vida alineado

Una FOREIGN KEY con ON DELETE CASCADE tiene sentido cuando:

  • Padre e hijo comparten ciclo de vida (por ejemplo, elementos de un carrito temporal).
  • La cardinalidad es razonable (decenas, cientos; no millones).

Ejemplo sano:

CREATE TABLE carts (
  id         BIGSERIAL PRIMARY KEY,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE cart_items (
  id       BIGSERIAL PRIMARY KEY,
  cart_id  BIGINT NOT NULL
           REFERENCES carts(id) ON DELETE CASCADE,
  sku      TEXT NOT NULL,
  qty      INT  NOT NULL
);

Aquí sí tiene sentido: el carrito no es entidad legal, su vida es corta, y borrar en cascada es razonable.

3.2 Grafo crítico vs grafo de soporte

Piensa tu esquema en dos "grafos":

  • Crítico de negocio: pedidos, facturas, pagos… normalmente sin ON DELETE CASCADE y con borrado controlado.
  • Soporte / auxiliar: sesiones, carritos temporales, notificaciones… candidatos naturales a cascadas y borrados masivos.

Diseña sabiendo en qué grafo juega cada tabla.


4. Soft delete bien hecho (y sus trampas)

4.1 El error clásico: solo marcar el padre

Ejemplo:

ALTER TABLE customers ADD COLUMN deleted_at TIMESTAMPTZ;

-- Pero orders sigue igual
-- FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT;

Consecuencias:

  • Puedes seguir creando pedidos para un cliente "borrado".
  • Informes mezclan clientes activos e inactivos sin querer.

Opciones para arreglarlo:

  1. Propagar estado lógico de borrado:

    • Añadir deleted_at a tablas hijas críticas.
    • Forzar en dominio de negocio: no crear pedidos si el cliente está borrado.
  2. Separar responsabilidades:

    • customers para datos operativos.
    • customers_legal / customers_billing para datos que nunca se borran.

4.2 Enmascarar vs borrar

Si el motivo del borrado es privacidad (RGPD, etc.), muchas veces no quieres borrar la fila, sino anonimizar campos:

UPDATE customers
SET name        = 'anon',
    email       = NULL,
    phone       = NULL,
    deleted_at  = now()
WHERE id = $1;

Patrones útiles:

  • Mantener el identificador pero romper el vínculo con la persona.
  • Mover datos sensibles a otra tabla con ciclo de vida distinto.

5. Auditoría sin destruir el rendimiento

5.1 Triggers inocentes que matan la base

Poner un trigger de auditoría en cada tabla que guarda cada cambio en una tabla gigante y sin particionar es una receta clara para:

  • Añadir latencia a todas las escrituras.
  • Generar una tabla de auditoría inmanejable.

5.2 Patrones más sostenibles

  1. Tablas de auditoría específicas y particionadas:

    CREATE TABLE order_events (
      id          BIGSERIAL PRIMARY KEY,
      order_id    BIGINT NOT NULL,
      event_type  TEXT   NOT NULL,
      payload     JSONB  NOT NULL,
      created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
    ) PARTITION BY RANGE (created_at);
    
    • Eventos inmutables.
    • Se pueden limpiar por particiones antiguas.
  2. Event sourcing parcial:

    • Para áreas críticas (pagos, cambios de estado), registra eventos explícitos desde la app en vez de depender de triggers implícitos.
  3. Logs de aplicación bien estructurados:

    • CSV/JSON en almacenamiento barato.
    • No todo tiene que vivir dentro de la base.

6. Migrar una BD existente sin parar el sistema

Llegamos a la parte más delicada: ya tienes ON DELETE CASCADE en producción, o un diseño de FKs que no te gusta. ¿Cómo lo cambias sin downtime?

6.1 Principio general: migraciones en dos (o más) fases

Nunca hagas en una sola migración lo que puedes partir en varias:

  1. Fase 1: añadir sin romper

    • Nuevas columnas opcionales.
    • Nuevas tablas sin usar todavía.
    • Nuevas FKs marcadas como NOT VALID (en Postgres) para no bloquear.
    ALTER TABLE orders
    ADD COLUMN customer_id_new BIGINT;
    
    ALTER TABLE orders
    ADD CONSTRAINT orders_customer_id_new_fkey
      FOREIGN KEY (customer_id_new)
      REFERENCES customers(id)
      NOT VALID;
    
  2. Fase 2: rellenar y validar en background

    UPDATE orders SET customer_id_new = customer_id WHERE customer_id_new IS NULL;
    
    ALTER TABLE orders
    VALIDATE CONSTRAINT orders_customer_id_new_fkey;
    
  3. Fase 3: cambiar código

    • Desplegar versión de la app que usa customer_id_new.
  4. Fase 4: limpiar lo viejo

    • Quitar FKs antiguas y columnas legacy.
    ALTER TABLE orders DROP CONSTRAINT orders_customer_id_fkey;
    ALTER TABLE orders DROP COLUMN customer_id;
    
    ALTER TABLE orders RENAME COLUMN customer_id_new TO customer_id;
    

6.2 Quitar ON DELETE CASCADE sin susto

Supón que tienes:

ALTER TABLE orders
  ADD CONSTRAINT orders_customer_id_fkey
  FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE;

Y quieres:

  • Mantener integridad.
  • Evitar borrados en cascada.

Estrategia:

  1. Auditar usos actuales:

    • Buscar en el código dónde se hace DELETE FROM customers.
    • Añadir logs temporales o vistas de monitorización sobre esas operaciones.
  2. Cambiar el patrón en código:

    • Implementar borrado explícito y controlado en la aplicación (o job batch):

      BEGIN;
        DELETE FROM orders   WHERE customer_id = $1;
        UPDATE customers
          SET deleted_at = now()
        WHERE id = $1;
      COMMIT;
      
  3. Cambiar la FK en la base:

    ALTER TABLE orders DROP CONSTRAINT orders_customer_id_fkey;
    ALTER TABLE orders
      ADD CONSTRAINT orders_customer_id_fkey
      FOREIGN KEY (customer_id)
      REFERENCES customers(id)
      ON DELETE RESTRICT;
    
  4. Monitorizar:

    • Capturar errores de integridad que antes se tapaban con CASCADE.

6.3 Cambios masivos: feature flags a nivel de datos

Para cambios peligrosos, piensa en "flags de datos":

  • Campos tipo use_new_logic BOOLEAN o schema_version INT.
  • Permiten activar el nuevo comportamiento sólo para un subconjunto controlado de datos.

Ejemplo:

ALTER TABLE customers ADD COLUMN schema_version INT NOT NULL DEFAULT 1;
  • Tu código aplica nueva lógica (sin cascadas, con soft delete, etc.) sólo cuando schema_version = 2.
  • Migras clientes en lotes pequeños incrementando la versión.

7. Checklist práctico para no romper tu base

Cuando diseñes o revises tu esquema relacional:

  1. Revisa cada ON DELETE CASCADE:

    • ¿Podría disparar un borrado masivo inesperado?
    • ¿Perderías información valiosa o legalmente necesaria?
  2. Clasifica tus tablas:

    • Críticas de negocio vs auxiliares / temporales.
    • Aplica borrado duro sólo a las segundas.
  3. Define qué significa "borrar" en tu dominio:

    • Borrar duro, marcar como inactivo, archivar, anonimizar…
  4. Planifica las migraciones en fases:

    • Añadir → rellenar → validar → cambiar código → limpiar.
  5. Audita las FKs existentes:

    • Detección de cadenas largas de cascadas.
    • Índices que faltan en columnas referenciadas.

Conclusión

ON DELETE CASCADE no es el villano; es el síntoma visible de decisiones de diseño de ciclo de vida, borrado y auditoría que a menudo se toman deprisa.

Si te quedas con algo, que sea esto:

  • Diseña pensando en el ciclo de vida real de cada entidad.
  • Usa borrado duro con moderación y en tablas auxiliares.
  • Prefiere soft delete, archivo o anonimización para datos de negocio.
  • Cambia el esquema en fases, sin asumir que una migración es atómica.

Tu yo del futuro (y el on-call del fin de semana) lo agradecerán.

Compartir:

Artículos relacionados