Saltar al contenido

Soft Deletes: la función que cuesta más de lo que ahorra

Los soft deletes parecen una red de seguridad, pero corrompen el esquema, contaminan las consultas y rompen restricciones: qué usar en su lugar.

6 min de lectura
Diagrama de esquema de PostgreSQL que ilustra la complejidad de los soft deletes y los patrones de índices parciales

Los soft deletes parecen simples: agregas una columna deleted_at, la filtras en cada consulta y lo despliegas. Aparecen en todos los tutoriales de ORM, en todos los boilerplates de SaaS, en toda guía de "cómo construir una aplicación multi-tenant". Se sienten como un seguro gratuito. En la práctica, son un desastre de esquema en cámara lenta que corrompe índices, rompe restricciones y genera fugas de datos invisibles que se acumulan durante años.

El atractivo es real: los hard deletes dan miedo, y deleted_at te da un botón de deshacer. Pero hay una brecha importante entre lo que prometen los soft deletes y lo que realmente cuestan a gran escala.

El problema de los índices

Cuando agregas deleted_at a una tabla con millones de filas, todos los índices existentes dejan de ser correctos. Un índice en email de la tabla users ahora cubre tanto las filas activas como las eliminadas, pero el 99% de tus consultas solo necesitan las activas. El índice crece con cada tombstone que acumulas, y la selectividad se degrada con el tiempo.

Los índices parciales de PostgreSQL resuelven esto, pero introducen un nuevo invariante que debes mantener para siempre:

sqlsql
-- ❌ Covers all rows — makes every active-user lookup scan deleted junk
CREATE INDEX idx_users_email ON users(email);
 
-- ✅ Partial index — only indexes rows you actually query
CREATE INDEX idx_users_email_active ON users(email)
WHERE deleted_at IS NULL;

La trampa: el planificador de consultas solo usa un índice parcial si la cláusula WHERE de la consulta implica el predicado del índice. Si olvidas deleted_at IS NULL en una sola consulta, terminas con un sequential scan. Cada desarrollador, cada script de migración, cada consulta analítica debe recordar el filtro, o el índice es decorativo.

Las restricciones únicas se rompen en silencio

Aquí es donde los equipos se queman de verdad. Tienes una restricción única en users(email). Un usuario elimina su cuenta. Tres meses después, alguien se registra con la misma dirección. Violación de la restricción, aunque no exista ningún registro activo con ese correo.

sqlsql
-- ❌ Prevents re-registration — the unique constraint sees deleted rows too
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
 
-- ✅ Partial unique index — enforces uniqueness only among active rows
CREATE UNIQUE INDEX users_email_active_unique
  ON users(email)
  WHERE deleted_at IS NULL;

PostgreSQL admite índices únicos parciales, y esto funciona sin problemas. MySQL no: te ves obligado a aplicar la unicidad en el código de la aplicación, lo que implica condiciones de carrera bajo carga concurrente. Dos solicitudes pueden pasar la verificación antes de que cualquiera de las dos confirme la transacción.

!

Las verificaciones de unicidad en la capa de aplicación no son seguras bajo carga concurrente. Usa restricciones a nivel de base de datos siempre que sea posible. Si tu base de datos no admite índices únicos parciales, los soft deletes y la unicidad son fundamentalmente incompatibles.

La contaminación de consultas se acumula con el tiempo

En el momento en que deleted_at IS NULL se vuelve un requisito, debe estar presente en cada consulta. Cada scope de ORM, cada SQL crudo, cada subconsulta, cada condición de join. Si lo olvidas una sola vez, terminas devolviendo datos eliminados a los usuarios.

Los equipos abordan esto de tres maneras: scopes por defecto en el ORM (que generan confusión cuando realmente necesitas las filas eliminadas), confiar en que los desarrolladores lo recuerden (no lo harán), o vistas que filtran por defecto (razonable, pero agrega otra capa de indirección). Ninguna de estas opciones cierra la brecha por completo.

tstypescript
// ❌ Easy to forget — forgetting exposes deleted records silently
const users = await db
  .select()
  .from(usersTable)
  .where(eq(usersTable.organizationId, orgId));
 
// ✅ Explicit default scope baked into a repository boundary
async function getActiveUsers(orgId: string): Promise<User[]> {
  return db
    .select()
    .from(usersTable)
    .where(
      and(
        eq(usersTable.organizationId, orgId),
        isNull(usersTable.deletedAt),
      ),
    );
}

Incluso con una capa de repositorio que protege las consultas de la aplicación, el SQL crudo sigue apareciendo en migraciones, scripts puntuales, herramientas administrativas y pipelines de analítica. El filtro debe existir en todos los lugares donde se necesita, o no es confiable.

El problema de las cascadas

Las claves foráneas con ON DELETE CASCADE son una de las mejores funciones que ofrecen las bases de datos relacionales: eliminas una fila padre y las filas hijas desaparecen automáticamente, de forma atómica, sin ninguna lógica de aplicación. Los soft deletes desactivan esto por completo. La base de datos cree que no se eliminó nada, así que las cascadas nunca se disparan.

Ahora necesitas lógica de cascada a nivel de aplicación. ¿Eliminar una organización? Encontrar todos sus usuarios, workspaces, invitaciones y API keys, y luego aplicar un soft delete a cada entidad dentro de una transacción. Y actualizar esa lógica cada vez que se agrega una nueva tabla hija.

tstypescript
// ❌ Manual cascade — someone will add a new table and forget this function
async function softDeleteOrganization(orgId: string, tx: Transaction) {
  await tx.update(usersTable)
    .set({ deletedAt: new Date() })
    .where(eq(usersTable.organizationId, orgId));
 
  await tx.update(workspacesTable)
    .set({ deletedAt: new Date() })
    .where(eq(workspacesTable.organizationId, orgId));
 
  // Invitations table added six months later — already a leak
  await tx.update(organizationsTable)
    .set({ deletedAt: new Date() })
    .where(eq(organizationsTable.id, orgId));
}

Cada nueva tabla hija introducida después de escribir esta función es una fuga de datos potencial. La base de datos aplicaba la integridad referencial de forma gratuita. Ahora la mantienes a mano a través de un número ilimitado de tipos de entidad.

Qué usar en su lugar

Antes de recurrir a deleted_at, pregúntate qué problema estás resolviendo en realidad:

ObjetivoMejor enfoque
Deshacer eliminaciones accidentalesRecuperación a un punto en el tiempo, backups basados en WAL
Historial de auditoría completo de los cambiosRegistro de auditoría append-only o event sourcing
Retener datos por facturación o cumplimiento normativoTabla de archivo con eliminaciones reales desde la tabla principal
Función UX de "papelera de reciclaje"deleted_at con un job de expiración explícito
Depuración de problemas en producciónLogs estructurados, change data capture

Tablas de archivo

Para requisitos de cumplimiento normativo, un job en segundo plano mueve las filas eliminadas a una tabla users_archive antes de aplicar un hard delete en users. La tabla principal se mantiene limpia, las restricciones funcionan, los índices se mantienen livianos, y el archivo se escribe una sola vez y nunca se toca en el hot path.

sqlsql
-- Archive first, then hard delete — main table stays clean
BEGIN;
 
INSERT INTO users_archive
SELECT *, NOW() AS archived_at
FROM users
WHERE id = $1;
 
DELETE FROM users WHERE id = $1;
 
COMMIT;

Registros de auditoría append-only

Si el requisito es "¿cómo se veía este registro hace seis meses?", un registro de auditoría te da el historial completo, no solo si la fila fue eliminada. Puedes reconstruir cualquier estado pasado reproduciendo los eventos, y la tabla de producción no tiene tombstones.

tstypescript
// Write the event, then hard delete — full history without polluting the table
async function deleteUser(userId: string, actorId: string, tx: Transaction) {
  const user = await tx.query.usersTable.findFirst({
    where: eq(usersTable.id, userId),
  });
 
  await tx.insert(auditLogTable).values({
    entityType: "user",
    entityId: userId,
    action: "deleted",
    payload: JSON.stringify(user),
    actorId,
    createdAt: new Date(),
  });
 
  await tx.delete(usersTable).where(eq(usersTable.id, userId));
}

Cuándo los soft deletes son aceptables

No siempre son incorrectos. Una columna deleted_at es defendible cuando la tabla es pequeña y su crecimiento está acotado, cuando estás construyendo explícitamente una función de papelera de reciclaje con un job de expiración programado, o cuando las preocupaciones de unicidad y cascada realmente no aplican a esa entidad. El error no es el patrón en sí, sino aplicarlo por defecto a todas las tablas del esquema sin considerar lo que rompe.

Puntos clave

  1. Los índices parciales ayudan, pero exigen disciplina total: cada consulta debe incluir el predicado del filtro, o el índice se ignora
  2. Las restricciones únicas y los soft deletes son incompatibles en MySQL y requieren índices únicos parciales en PostgreSQL: revisa tu base de datos antes de comprometerte con el enfoque
  3. Las cascadas a nivel de aplicación se pudren: cada nueva tabla hija es una fuga de datos potencial a menos que la función de cascada se mantenga sincronizada
  4. Las tablas de archivo resuelven el requisito de cumplimiento de forma limpia: la tabla principal permanece normalizada, y el archivo es append-only y queda fuera del hot path
  5. Los registros de auditoría te dan el historial completo en lugar de solo una marca de tiempo de eliminación, y combinan bien con event sourcing
  6. Haz coincidir la herramienta con el requisito real: papelera de reciclaje → deleted_at con expiración; cumplimiento normativo → tabla de archivo; historial de auditoría → registro de eventos append-only
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX