Más allá de ON DELETE CASCADE: patrones reales para no romper tu base de datos
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
DELETEinocente 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:
- ¿Es aceptable perder esta información para siempre?
- ¿El volumen potencial de filas a borrar es acotado y controlable?
- ¿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:
Vistas de sólo activos:
CREATE VIEW active_customers AS SELECT * FROM customers WHERE deleted_at IS NULL;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 CASCADEpara 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 CASCADEy 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:
Propagar estado lógico de borrado:
- Añadir
deleted_ata tablas hijas críticas. - Forzar en dominio de negocio: no crear pedidos si el cliente está borrado.
- Añadir
Separar responsabilidades:
customerspara datos operativos.customers_legal/customers_billingpara 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
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.
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.
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:
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;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;Fase 3: cambiar código
- Desplegar versión de la app que usa
customer_id_new.
- Desplegar versión de la app que usa
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:
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.
- Buscar en el código dónde se hace
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;
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;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 BOOLEANoschema_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:
Revisa cada
ON DELETE CASCADE:- ¿Podría disparar un borrado masivo inesperado?
- ¿Perderías información valiosa o legalmente necesaria?
Clasifica tus tablas:
- Críticas de negocio vs auxiliares / temporales.
- Aplica borrado duro sólo a las segundas.
Define qué significa "borrar" en tu dominio:
- Borrar duro, marcar como inactivo, archivar, anonimizar…
Planifica las migraciones en fases:
- Añadir → rellenar → validar → cambiar código → limpiar.
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.


