Módulo 2: Soft Deletes Correctos
Soft delete vs hard delete: trade-offs
Descripción de la cápsula
"Borra ese registro" suena trivial. En una app real, no lo es. Hard delete elimina la fila físicamente: liberas espacio, simplificas el modelo, cumples regulaciones de retención. Soft delete marca la fila como borrada pero la conserva: tienes recovery, audit trail, y puedes deshacer accidentes. Cada opción tiene un costo invisible que recién aparece en producción, cuando ya es tarde para cambiar la decisión.
En esta cápsula no aprendes a implementar ninguno de los dos (eso viene en la 03). Aprendes a decidir cuál corresponde a tu caso. Vas a ver los costos reales de cada opción (espacio, performance, compliance, complejidad operacional), un decision tree que puedes aplicar en cualquier code review, y los casos donde soft delete NO es la respuesta correcta y elegirlo te genera deuda técnica acumulada.
El objetivo es que al terminar puedas defender tu decisión ante un compañero senior con criterios concretos, no con "es lo que se hace siempre". El patrón equivocado aplicado por inercia es la deuda técnica más cara de revertir en una codebase de SaaS.
El framing: "borrar" no es una operación, es una decisión de modelado
Cuando un usuario hace clic en el botón "eliminar tarea", lo que parece un evento atómico esconde varias decisiones:
- ¿La fila debe desaparecer físicamente o quedar marcada?
- Si queda marcada, ¿por cuánto tiempo? ¿Para siempre? ¿Hasta que un job la limpie?
- ¿El usuario puede deshacer el borrado? ¿Cuánto tiempo después?
- ¿El histórico (audit log, reportes, analytics) cuenta esta fila o la excluye?
- ¿Otras filas que apuntan a esta (foreign keys) deben borrarse, marcarse o quedar huérfanas?
- ¿Compliance regula la retención (debes guardarla X años) o el borrado (debes eliminarla bajo demanda)?
Hard delete responde "1: desaparece, 2: nunca existió, 3: no, 4: no aparecen, 5: cascade, 6: cumples borrado pero pierdes retención". Soft delete responde "1: marcada, 2: para siempre por default, 3: sí, 4: depende del query, 5: depende del modelo, 6: cumples retención pero violas borrado".
Ninguna de las dos respuestas es correcta universalmente. La pregunta es: para tu dominio, ¿cuál columna pesa más?
Modelo mental: borrar como compromiso, no como acción
Piensa en el borrado como en una caja fuerte de banco. Hard delete es romper la caja con un mazo: no hay forma de recuperar lo que estaba adentro, pero la caja deja de ocupar espacio en la bóveda. Soft delete es cerrar la caja con candado y poner una etiqueta de "no abrir": el contenido sigue ahí, ocupa espacio, alguien con la llave puede abrirla, pero el cliente la ve como "borrada".
Cada cliente del banco quiere algo distinto. Algunos quieren la garantía de que su contenido desaparezca al cerrar la cuenta (GDPR). Otros quieren poder pedir el contenido de vuelta seis meses después (recuperación). Algunos están bajo regulación que les obliga a guardarlo siete años (compliance fiscal).
Tu API es el banco. La pregunta no es "¿cómo borro?", es "¿qué tipo de banco quieres ser para tus clientes?".
Lo que ganas con cada opción
Hard delete (DELETE FROM tasks WHERE id = $1)
Ventajas reales:
- Espacio: la fila libera espacio en la tabla y en cada índice que la incluía. Después del próximo VACUUM, ese espacio se reutiliza. Sin acumulación de "basura" a largo plazo.
- Simplicidad de queries: todas las queries son
SELECT * FROM tasks WHERE .... No hay que recordar filtros adicionales. No hay que automatizar nada. Lo que está, está vivo. - Performance estable a largo plazo: la tabla solo crece con datos activos. Los índices se mantienen densos. No hay degradación por acumulación.
- GDPR right-to-be-forgotten cumple por default: si el dato no existe, no hay que demostrar que se borró.
- Foreign keys con
ON DELETE CASCADEson determinísticas: la cascada elimina dependencias y se acabó.
Costos reales:
- Pérdida de información: si el usuario se arrepiente, no hay vuelta atrás. "Restauración desde backup" suena bien hasta que recuerdas que un backup tiene latencia de horas y no es selectivo (restauras toda la DB o nada).
- Audit trail roto: no puedes responder "¿qué pasó con la task #1234?". La fila no está. El log dice que se borró el 15 de marzo, pero el contenido se fue con ella.
- Foreign keys tienen que estar pensadas: si
comments.task_idreferenciatasks.idy borras la task, el cascade elimina los comments. Si NO usas cascade, recibes un error de FK violation. Si usasON DELETE SET NULL, te quedas con comments huérfanos. Cada caso requiere decisión explícita. - Imposible auditar borrados: "¿quién borró esta task?" — ningún log lo dice si no construiste audit log paralelo.
Soft delete (UPDATE tasks SET deleted_at = NOW() WHERE id = $1)
Ventajas reales:
- Recuperación trivial:
UPDATE tasks SET deleted_at = NULL WHERE id = $1y la fila vuelve a estar viva. Cuesta nada implementar undo. - Audit trail preservado: la fila sigue ahí con su contenido. Puedes responder "¿qué decía la task #1234 antes de borrarse?" leyendo la fila directamente.
- Foreign keys siguen siendo válidas: los registros relacionados no quedan huérfanos. Un comment a una task borrada sigue apuntando a una fila existente.
- Métricas históricas funcionan: "¿cuántas tasks creó este usuario en marzo?" cuenta también las borradas, sin perder visibilidad histórica.
- Compliance de retención cumple: si la regulación dice "guarda los datos siete años", soft delete los guarda.
Costos reales:
- Espacio acumulado: las filas borradas siguen ocupando lugar en tabla e índices. En tablas con alto ratio de borrados (>50%), el bloat puede triplicar el tamaño. VACUUM no recupera ese espacio (hay que hacer
VACUUM FULL, que toma lock exclusivo). - Performance degradada si no se maneja: sin índice parcial, cada query con
WHERE deleted_at IS NULLrecorre filas borradas que después descarta. Es N+1 disfrazado. Lo verás medido en la cápsula 03. - Riesgo de filtro olvidado: un endpoint nuevo escrito sin recordar el filtro devuelve registros borrados a los clientes. Bug semántico, difícil de detectar en QA, fácil de propagar.
- GDPR right-to-be-forgotten NO cumple: los datos personales siguen ahí. Marcar
deleted_atno satisface "borra mis datos". Necesitas combinar soft delete + anonimización + hard delete diferido. - Cascade no es trivial: si borras un usuario, ¿se borran sus tasks? ¿Se marcan también? ¿Quedan huérfanas con un usuario "borrado" referenciado? No hay respuesta única; cada relación necesita decisión explícita.
- JOINs requieren atención: si haces
JOIN users ON tasks.author_id = users.id, ¿filtras tambiénusers.deleted_at IS NULL? Si no, muestras tasks de usuarios borrados. Si sí, las tasks legítimas de usuarios borrados desaparecen.
Caso desarrollado: la misma operación con cada opción
Veamos qué hace PostgreSQL en cada caso. Tabla:
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
author_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
Hard delete
BEGIN;
DELETE FROM tasks WHERE id = 1234;
-- DELETE 1
COMMIT;
Lo que pasa internamente:
- PostgreSQL marca la fila como "borrada" en su MVCC (visibility map). La fila sigue físicamente en la tabla.
- La fila deja de ser visible para nuevas transacciones.
- Las foreign keys (
comments.task_idpor ejemplo) ejecutan su política:CASCADEborra dependencias,RESTRICTfalla si hay dependencias,SET NULLlas marca comoNULL. - En el próximo VACUUM, el espacio físico se reutiliza para nuevas inserciones.
Si después del COMMIT alguien quiere recuperarla: necesita ir al backup. Suerte.
Soft delete
BEGIN;
UPDATE tasks SET deleted_at = NOW() WHERE id = 1234;
-- UPDATE 1
COMMIT;
Lo que pasa internamente:
- PostgreSQL crea una nueva versión de la fila con
deleted_at = NOW()(MVCC). - La versión vieja queda marcada como "no visible para transacciones nuevas". Sigue físicamente en la tabla hasta el próximo VACUUM.
- Las foreign keys NO disparan nada: la fila sigue existiendo desde el punto de vista del schema.
- Cualquier query nueva debe filtrar
WHERE deleted_at IS NULLpara no verla.
Si después del COMMIT alguien quiere recuperarla:
UPDATE tasks SET deleted_at = NULL WHERE id = 1234;
Listo. La fila vuelve a ser visible.
Lo que cambia en queries posteriores
Hard delete:
SELECT id, title FROM tasks WHERE author_id = 42 ORDER BY created_at DESC LIMIT 50;
-- Devuelve solo tasks vivas (las borradas ya no existen)
Soft delete:
-- Olvidaste el filtro: devuelve tasks borradas también
SELECT id, title FROM tasks WHERE author_id = 42 ORDER BY created_at DESC LIMIT 50;
-- Filtro correcto: devuelve solo tasks vivas
SELECT id, title FROM tasks
WHERE author_id = 42 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 50;
Esa diferencia es exactamente el bug semántico más caro del patrón. En hard delete es imposible olvidar el filtro porque no hay filtro. En soft delete, olvidarlo es un error en cada query nueva del codebase. Por eso el módulo invierte una cápsula entera (la 04) en cómo automatizar el filtro.
Decision tree: ¿cuál usar?
¿La regulación te obliga a borrar bajo demanda (GDPR, CCPA, HIPAA en algunos casos)?
├─ SÍ → necesitas hard delete o soft delete + anonimización + hard delete diferido.
│ (soft delete solo NO cumple)
└─ NO → siguiente pregunta
¿Los usuarios necesitan deshacer el borrado en algún horizonte (minutos, días, meses)?
├─ SÍ → soft delete (la opción que da recuperación trivial)
└─ NO → siguiente pregunta
¿El audit trail / reportes históricos / analytics necesitan ver registros borrados?
├─ SÍ → soft delete (preserva histórico sin ETL adicional)
└─ NO → siguiente pregunta
¿La tabla puede crecer más de 100M de filas con ratio de borrados >50%?
├─ SÍ → soft delete deja de escalar; considera archive tables (cápsula 07)
└─ NO → siguiente pregunta
¿La complejidad de mantener el filtro automático en cada query es aceptable para el equipo?
├─ SÍ → soft delete (con mecanismo automático del módulo)
└─ NO → hard delete (más simple, menos cosas que olvidar)
Resumen: soft delete gana cuando el dominio valora recuperación + audit + métricas históricas, y la regulación lo permite. Hard delete gana cuando la simplicidad pesa más que el undo, o la regulación lo exige.
Casos donde la respuesta es clara
Soft delete sin dudarlo:
- Tareas, proyectos, documentos editables por el usuario (caso TaskFlow).
- Comentarios donde el usuario podría arrepentirse.
- Productos en un catálogo (un producto descontinuado puede volver).
- Suscripciones canceladas que pueden reactivarse.
Hard delete sin dudarlo:
- Tokens de sesión expirados (no tiene sentido recuperarlos).
- Cache entries (datos derivados, regenerables).
- Datos personales de usuarios que ejercen GDPR right-to-be-forgotten.
- Eventos de telemetría más viejos que la ventana de retención.
- Tablas de log con TTL natural (errores, requests, métricas raw).
Casos ambiguos que dependen del producto:
- Mensajes de chat (¿puede el usuario "borrar para todos"? ¿queda audit trail interno?).
- Pagos / transacciones (suele ganar audit pero compliance manda).
- Cuentas de usuario (típicamente hybrid: marca como deactivated, después hard delete con cron).
El caso especial de GDPR (y por qué soft delete no alcanza)
GDPR Article 17 (right-to-be-forgotten) dice que un usuario puede pedir que sus datos personales se borren. Soft delete no satisface esto: los datos siguen ahí, solo están marcados.
La solución estándar en SaaS es un patrón en tres pasos:
- Soft delete inmediato:
UPDATE users SET deleted_at = NOW() WHERE id = $1. La cuenta deja de ser visible. - Anonimización síncrona: sobrescribir campos personales con valores genéricos.
UPDATE users SET email = 'deleted-' || id || '@anonymized', name = 'Deleted User', phone = NULL WHERE id = $1. Los datos personales desaparecen, pero la fila queda con FK válida para no romper joins históricos. - Hard delete diferido: un job nocturno ejecuta
DELETE FROM users WHERE deleted_at < NOW() - INTERVAL '30 days'después de un período de gracia. Las foreign keys deben tenerON DELETE SET NULLpara los campos que deban quedar disponibles, oCASCADEpara los que se vayan con el usuario.
Este patrón cumple GDPR (los datos personales no quedan después de 30 días) y permite recuperación durante la ventana de gracia. Es el único caso donde soft delete y hard delete coexisten en la misma tabla.
Profundización en compliance (qué considera GDPR como "dato personal", excepciones, logs de borrado para auditorías) está fuera del scope de esta guía. Si trabajas con datos regulados, consulta con tu equipo legal antes de elegir estrategia.
¿Por qué importa esta decisión en el trabajo real?
1. Las decisiones de modelado son las más caras de revertir. Cambiar de hard a soft delete a posteriori implica añadir columna, hacer backfill (todos los borrados pasados se perdieron, así que solo aplica a futuros), modificar todas las queries, automatizar el filtro, y rehacer tests. Cambiar de soft a hard implica perder histórico que probablemente otra parte del sistema (reportes, audit) ya consume. Ninguno es trivial.
2. Los costos invisibles aparecen tarde. Soft delete sin índice parcial es invisible en QA con 100 filas. Aparece en producción cuando llegas a 1M y el endpoint pasa de 5ms a 800ms. Hard delete sin recovery es invisible hasta que un cliente borra por accidente y pide restaurar.
3. Code reviews te van a preguntar esto. Cuando propongas un endpoint DELETE /tasks/{id} en un PR, un reviewer senior va a preguntar "¿hard o soft? ¿por qué?". Tener la respuesta lista con criterios (no con preferencia) es nivel senior.
4. El patrón equivocado escala mal. Soft delete aplicado a una tabla de eventos de telemetría con 200M de filas mensuales es deuda inmediata: cada mes acumulas más rows borradas que vivas. Hard delete aplicado a tasks editables por el usuario genera tickets de soporte. Saber elegir el patrón correcto desde el día 1 ahorra meses de migración después.
Trampas y errores comunes
Error 1 (conceptual): "soft delete es siempre más seguro"
Síntoma: equipo aplica soft delete a todo por default, "por las dudas".
Por qué es erróneo: soft delete tiene costos (espacio, performance, complejidad, GDPR risk) que se acumulan en cada tabla donde se aplica sin necesidad. Una tabla de session_tokens con soft delete acumula tokens expirados eternamente, los índices se inflan y queries de auth se ralentizan, todo para preservar información que nadie va a consultar.
Cómo distinguir: pregúntate "¿alguien va a querer recuperar un session_token expirado en algún momento?". Si la respuesta es no, soft delete es overhead injustificado.
Cómo corregir: decisión explícita por tabla, no por default. Documenta el criterio en el modelo (un comentario en el ORM o el schema).
Error 2 (conceptual): "soft delete cumple GDPR porque el dato 'desaparece' del API"
Síntoma: equipo cree estar compliant con soft delete porque el cliente no ve sus datos después de borrar la cuenta.
Por qué es erróneo: GDPR no se preocupa por lo que el cliente ve, se preocupa por lo que tu DB almacena. Si el regulador audita y los datos personales siguen ahí, no estás compliant. Aunque el cliente ya no pueda verlos, tus backups, reportes internos y administradores sí.
Cómo distinguir: revisa qué datos tiene una fila "soft-deleted". ¿Hay PII (nombre, email, teléfono, dirección)? Si sí, soft delete solo no alcanza.
Cómo corregir: patrón soft delete + anonimización + hard delete diferido descrito arriba.
Error 3 (práctico): cascade silencioso con ON DELETE CASCADE y soft delete
Síntoma: tu modelo tiene comments.task_id REFERENCES tasks(id) ON DELETE CASCADE. Decides cambiar a soft delete: ahora UPDATE tasks SET deleted_at = NOW(). Los comments siguen ahí. Pero el día que algún job ejecuta hard delete real (por compliance, por GC, lo que sea), los comments se van con cascade — y si tenían su propio soft delete, los pierdes sin marca de borrado.
Por qué pasa: cascade es a nivel de schema, no a nivel de aplicación. La transición a soft delete a nivel de app no cambia el comportamiento del schema cuando alguien sí ejecuta DELETE.
Cómo distinguir: lista las FK con cascade en tu schema (information_schema.referential_constraints). Cualquier tabla con soft delete + FK con cascade es candidata a inconsistencia.
Cómo corregir: decide explícitamente la política. Suele ser cambiar CASCADE por RESTRICT o NO ACTION, y manejar la cascada a nivel de aplicación. La cápsula 06 lo profundiza.
Error 4 (edge case): hard delete sobre filas referenciadas sin handler de FK
Síntoma: ejecutas DELETE FROM users WHERE id = $1 y recibes ERROR: update or delete on table "users" violates foreign key constraint "tasks_author_id_fkey" on table "tasks". La operación falla en producción y devuelve 500 al usuario.
Por qué pasa: la FK no tiene política de cascade y hay tasks que apuntan al user. PostgreSQL te protege de dejar tasks huérfanas, pero el error sale al usuario.
Cómo distinguir: intenta el DELETE en un environment de test contra una fila con dependencias. Si revienta, tu app no maneja el caso.
Cómo corregir: decide la política a nivel de schema (CASCADE, SET NULL, RESTRICT) y maneja el error en la app cuando aplique. En SaaS, la respuesta suele ser "no permitas hard delete si hay dependencias; usa soft delete o pide al usuario eliminar las dependencias primero".
Ejercicios
Ejercicio 1: clasifica cada tabla
Para cada caso, decide entre hard delete, soft delete o hybrid (soft + hard delete diferido), y justifica:
a) Tabla password_reset_tokens con TTL de 1 hora. Cada token se usa una vez.
b) Tabla invoices en una app de facturación. La regulación fiscal exige guardar facturas 7 años. Los usuarios pueden "anular" una factura.
c) Tabla users en un SaaS B2B. Los usuarios pueden cancelar su cuenta. La regulación GDPR aplica.
d) Tabla audit_events que registra cada acción del sistema. Volumen de 10M de eventos por mes.
e) Tabla cart_items de un carrito de compras. Los usuarios añaden y quitan items constantemente.
Ver solución
a) Hard delete. Los tokens son efímeros, no hay valor en preservarlos después de uso o expiración. Soft delete acumula basura sin beneficio. Adicionalmente, conviene un job que ejecute DELETE FROM password_reset_tokens WHERE created_at < NOW() - INTERVAL '24 hours' para limpiar tokens vencidos no usados.
b) Soft delete. Compliance fiscal exige retención larga; "anular" es semánticamente "marcar como inválida sin eliminar". El campo no debería ser deleted_at sino algo dominio-específico como voided_at con la razón de anulación. Hard delete violaría retención.
c) Hybrid. Soft delete inmediato (recuperación durante grace period), anonimización síncrona de PII, hard delete diferido después de 30 días por compliance GDPR. Es el patrón de tres pasos descrito en la cápsula.
d) Hard delete con TTL. 10M de eventos por mes acumula 120M anuales. Soft delete inflaría la tabla sin beneficio (los audit events viejos suelen no consultarse). Mejor: archivar a S3 + hard delete después de 90 días, o usar una tabla particionada por mes con DROP PARTITION viejas. El módulo 7 cubre el detalle.
e) Hard delete. Carrito es estado efímero del usuario, no hay valor en preservar items quitados. Soft delete inflaría la tabla sin que nadie consulte ese histórico. Si el negocio quiere "carritos abandonados" para email marketing, eso es un audit log separado, no soft delete del carrito.
Lección: la decisión depende de (1) si alguien va a consultar los datos borrados, (2) compliance, (3) volumen y patrón de churn. No hay default universal.
Ejercicio 2: identifica el costo invisible
Te muestran este modelo en code review:
class SessionToken(Base):
__tablename__ = "session_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
token_hash: Mapped[str]
expires_at: Mapped[datetime]
deleted_at: Mapped[datetime | None] = mapped_column(default=None) # soft delete
__table_args__ = (
Index("idx_session_token_hash", "token_hash"),
Index("idx_session_user", "user_id"),
)
¿Qué problemas detectas? La app tiene 100k usuarios activos, cada uno genera ~5 tokens por día (login desde celular + web), TTL de 7 días, ratio de churn alto.
Ver solución
Volumen y degradación:
- 100k usuarios × 5 tokens/día = 500k tokens nuevos por día.
- Con TTL de 7 días, hay ~3.5M tokens activos en cualquier momento.
- Si soft delete preserva todo, en 1 año la tabla acumula ~180M de filas borradas + 3.5M activas. Ratio de borrados: 98%.
Problemas concretos:
-
Soft delete injustificado: ¿quién va a consultar un session token expirado o revocado? Nadie. Soft delete acumula 98% de basura sin propósito.
-
Sin índice parcial:
idx_session_token_hashyidx_session_userindexan también las filas borradas. Cada lookup recorre filas inútiles. -
Bloat masivo: 180M filas vivas en disco + sus índices = decenas de GB de espacio sin uso real. VACUUM no lo recupera (solo compacta páginas, no las achica).
-
Riesgo de seguridad: un token revocado por logout sigue en la DB. Si alguien lee esa tabla por otra razón, ve tokens (aunque hashed) que en teoría ya no son válidos. Ataque pasivo: harvesting de tokens viejos.
Recomendación:
- Cambiar a hard delete inmediato en logout/expiración.
- Job nocturno:
DELETE FROM session_tokens WHERE expires_at < NOW(). - Si hace falta audit trail de logins, eso es un audit_events table separada con TTL propio.
Patrón general: cualquier tabla con TTL natural, bajo valor histórico y alto churn es candidata a hard delete. Soft delete es para datos editables por el usuario donde el undo o el audit aporta valor.
Ejercicio 3: rediseñar el patrón GDPR
Tu app de SaaS B2B tiene esta tabla:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Un cliente ejerce GDPR right-to-be-forgotten. Diseña el flujo de tres pasos (soft delete + anonimización + hard delete diferido). Define qué columnas tocar, en qué momento, y qué pasa con las foreign keys (asume que tasks.author_id REFERENCES users.id).
Ver solución
Paso 1 — Modelo extendido:
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ NULL;
ALTER TABLE users ADD COLUMN anonymized_at TIMESTAMPTZ NULL;
-- Índice parcial para queries de usuarios activos
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;
Paso 2 — FK con política explícita:
-- tasks.author_id debe seguir apuntando a una fila válida después del hard delete.
-- Si la regla del producto es "las tasks históricas conservan referencia al user borrado",
-- usar SET NULL:
ALTER TABLE tasks
DROP CONSTRAINT tasks_author_id_fkey,
ADD CONSTRAINT tasks_author_id_fkey
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL;
-- Alternativa: si las tasks deben borrarse con el user, usar CASCADE.
-- Cada FK requiere decisión explícita.
Paso 3 — Endpoint de borrado (soft + anonimización síncrona):
async def delete_user_gdpr(session: AsyncSession, user_id: int) -> None:
"""
Soft delete + anonimización síncrona.
Hard delete lo ejecuta un job nocturno después del grace period.
"""
now = datetime.now(timezone.utc)
await session.execute(
update(User)
.where(User.id == user_id)
.values(
email=f"deleted-{user_id}@anonymized.local",
full_name="Deleted User",
phone=None,
deleted_at=now,
anonymized_at=now,
)
)
await session.commit()
Paso 4 — Job de hard delete diferido (cron nocturno):
async def hard_delete_expired_users(session: AsyncSession) -> int:
"""
Elimina físicamente users con deleted_at > 30 días.
Las FKs con SET NULL dejan tasks huérfanas con author_id NULL.
Las FKs con CASCADE eliminan tasks en cascada.
"""
result = await session.execute(
delete(User).where(
User.deleted_at < datetime.now(timezone.utc) - timedelta(days=30)
)
)
await session.commit()
return result.rowcount
Por qué cada paso:
- Soft delete primero: durante el grace period (30 días) el cliente puede pedir reactivación. Es práctica común; cumple "spirit of GDPR" sin frustrar al cliente que se arrepiente.
- Anonimización síncrona: la PII (email, nombre, teléfono) sale de la DB inmediatamente. Si un regulador audita ese día, no encuentra datos personales.
- Hard delete diferido: después de 30 días, la fila se va físicamente. La FK con
SET NULLoCASCADEdecide qué pasa con datos relacionados.
Caveats:
- Backups de la DB siguen teniendo la PII durante el período de retención de backups. Documenta esto en tu política de privacidad y mantén los backups encriptados.
- Logs de la app (donde el email pudo aparecer en mensajes de error) requieren proceso paralelo de purga.
- Datos en sistemas externos (analytics, mailing list, CRM) requieren delete API calls a cada uno.
Ejercicio 4: defender la decisión en code review
Un compañero junior abre PR con:
@app.delete("/sessions/{session_id}")
async def delete_session(session_id: int, db: AsyncSession = Depends(get_session)):
await db.execute(
update(SessionToken)
.where(SessionToken.id == session_id)
.values(deleted_at=datetime.now(timezone.utc))
)
await db.commit()
return {"status": "deleted"}
Justifica en un comentario de review por qué deberían cambiar a hard delete, citando criterios concretos (no opinión personal). Tu mensaje debe convencer al junior y al tech lead que aprobó el PR.
Ver solución
Comentario de review (ejemplo):
Sugiero cambiar a hard delete (
DELETE FROM session_tokens WHERE id = $1) por estas razones concretas:1. La tabla session_tokens no tiene valor histórico. Un session token revocado por logout o expiración no se consulta nunca después. Soft delete acumula filas que nadie va a leer. Con nuestro volumen (~500k tokens/día según métricas de auth), en 6 meses tendremos ~90M de filas vivas, 99% borradas. El bloat va a degradar el lookup por
token_hashque ejecutamos en cada request autenticado.2. Sin índice parcial, el costo es inmediato. El índice
idx_session_token_hashcubre filas borradas y vivas. Cada lookup de auth recorre filas inútiles. Medible: hoy estamos en p95=2ms, en 6 meses estaremos en p95=15-20ms si no cambiamos. Esto degrada cada request autenticado de la API.3. Riesgo de seguridad: tokens revocados visibles en la DB. Aunque el token está hasheado, mantener tokens revocados en la DB es superficie de ataque adicional. Si alguien lee esta tabla por otra razón (bug en otro endpoint, dump de DB), tiene acceso a tokens que en teoría ya no son válidos. Hard delete elimina ese riesgo.
4. No hay caso de uso para "deshacer logout". Soft delete tiene sentido cuando el usuario podría querer recuperar lo borrado. Aquí no aplica: si el usuario quiere volver a entrar, hace login de nuevo y genera token nuevo. Ningún flujo del producto justifica preservar el token viejo.
Propuesta concreta:
await db.execute(delete(SessionToken).where(SessionToken.id == session_id))Y agregar un job nocturno
DELETE FROM session_tokens WHERE expires_at < NOW()para limpiar tokens expirados que no se borraron explícitamente (cierre de browser sin logout, etc.).Si después necesitamos audit trail de "quién hizo logout cuándo", eso va en la tabla
audit_events, no ensession_tokens.
Por qué este comentario funciona:
- Cita números concretos (volumen, p95 esperado).
- Vincula la decisión a un criterio (valor histórico nulo + alto churn = hard delete).
- Anticipa la objeción "¿y si necesitamos audit?" con respuesta lista.
- No es opinión, es argumento. El junior aprende un patrón de pensamiento, no una preferencia personal.
Ejercicio 5: detectar el cascade silencioso
En tu DB tienes:
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
deleted_at TIMESTAMPTZ NULL
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
deleted_at TIMESTAMPTZ NULL
);
Tu app usa soft delete en ambas tablas. ¿Qué problema potencial existe? ¿Cómo lo verificas y cómo lo corriges?
Ver solución
Problema:
El schema tiene ON DELETE CASCADE en tasks.project_id. Esto solo se dispara con DELETE FROM projects WHERE id = $1 (hard delete). Mientras la app usa soft delete (UPDATE projects SET deleted_at = NOW()), no pasa nada con las tasks: siguen apuntando al project con deleted_at no nulo.
Aparentemente todo bien. Pero:
- Si algún día alguien (un script de mantenimiento, una migration, un job de GC) ejecuta
DELETEreal contra projects "soft-deleted" para liberar espacio, el cascade se dispara y elimina físicamente las tasks que tenían su propiodeleted_at(o sus tasks vivas si soft delete del project no se propagó). - La cascada destruye el audit trail que soft delete intentaba preservar.
Cómo verificas:
-- Listar foreign keys con CASCADE en tablas que tienen deleted_at
SELECT
tc.table_name AS child_table,
kcu.column_name AS child_column,
ccu.table_name AS parent_table,
rc.delete_rule
FROM information_schema.referential_constraints rc
JOIN information_schema.table_constraints tc
ON rc.constraint_name = tc.constraint_name
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON rc.unique_constraint_name = ccu.constraint_name
WHERE rc.delete_rule = 'CASCADE'
AND tc.table_name IN (
SELECT table_name FROM information_schema.columns
WHERE column_name = 'deleted_at'
);
Cualquier resultado es candidato a auditar.
Cómo corriges:
Decisión explícita por relación. Las opciones:
- Cambiar a
RESTRICToNO ACTION: prohibir hard delete del parent si hay children. Forzar a borrar children primero. Más seguro. - Cambiar a
SET NULL: los children quedan conproject_id = NULL. Útil si tiene sentido conceptualmente (tasks "huérfanas"). - Mantener
CASCADEpero documentar y testear: si el dominio acepta que hard delete del project elimine todo, está bien — pero entonces el job de hard delete diferido debe ser explícito y testeado.
-- Opción 1: RESTRICT (más conservadora)
ALTER TABLE tasks
DROP CONSTRAINT tasks_project_id_fkey,
ADD CONSTRAINT tasks_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE RESTRICT;
Lección: soft delete a nivel de aplicación NO cambia las semánticas del schema. Las foreign keys siguen funcionando como están definidas. Cualquier transición a soft delete debe revisar las cascadas existentes.
Resumen y siguiente paso
En esta cápsula aprendiste:
- Borrar es decisión de modelado, no operación atómica. Cada borrado responde implícitamente a 6 preguntas (visibilidad, retención, undo, métricas, FK, compliance).
- Hard delete gana en simplicidad y compliance GDPR; soft delete gana en recuperación, audit y métricas históricas. Ningún patrón es universal.
- El decision tree te lleva a la respuesta correcta basado en regulación, undo, métricas, escala y complejidad operacional.
- Patrones híbridos existen y suelen ser la respuesta para datos personales bajo GDPR: soft delete + anonimización + hard delete diferido.
- Cascade silencioso es la trampa más común al introducir soft delete sobre un schema con foreign keys CASCADE existentes. Audita las FK al introducir soft delete.
Antes de avanzar deberías poder:
- Decidir entre hard y soft delete para una tabla nueva con criterios concretos (no preferencia).
- Identificar cuándo un equipo está aplicando soft delete por inercia donde no aporta valor.
- Diseñar el patrón hybrid (soft + anonimización + hard delete diferido) para una tabla con PII bajo GDPR.
- Auditar foreign keys con CASCADE en tablas con
deleted_atpara detectar inconsistencias.
Siguiente cápsula — Implementación de deleted_at en PostgreSQL. Vas a aterrizar el patrón en código real: schema con deleted_at TIMESTAMPTZ, índice parcial WHERE deleted_at IS NULL, mediciones de antes y después con EXPLAIN ANALYZE. La cápsula es 100% PostgreSQL puro; SQLAlchemy llega en la 04.
Recursos
- Cultured Systems — "Avoiding the soft delete anti-pattern" — el caso más fuerte contra soft delete por default. Lectura recomendada antes de aplicar el patrón a una tabla nueva.
- Brandur Leach — "Soft deletion probably isn't worth it" — caso real de Stripe. Por qué soft delete deja de ser sostenible cuando la tabla escala y cómo migrar a archive tables.
- Heroku Engineering — "Why Soft Deletion is Evil" — el contraargumento clásico. Útil para entender el caso opuesto.
- Milan Jovanovic — "Implementing the Soft Delete Pattern" — discusión de mecanismos automáticos en otros stacks; aplicable conceptualmente.
- GDPR Article 17 — Right to erasure — el texto oficial. Lectura obligatoria si tu app maneja datos de residentes europeos.
- PostgreSQL Documentation — Foreign Keys — referencia de las políticas
CASCADE,RESTRICT,SET NULL,NO ACTION. - Sequin — "PostgreSQL Soft Deletes: Implementation Strategies" — comparación de estrategias a nivel de schema, complementa esta cápsula.
Módulo 2 — SQL Patterns for Production APIs Guide
Siguiente cápsula: Implementación de deleted_at en PostgreSQL.