Soft Delete: Patterns and Pitfalls

BackendDatabasesArchitecture
Share on LinkedIn

Soft delete feels like free undo until production queries return competitor data from "deleted" accounts because one repository method forgot deleted_at IS NULL. The pattern is widespread; the discipline around it usually isn't.

Basic implementation

CREATE TABLE customers (
  id          BIGSERIAL PRIMARY KEY,
  email       VARCHAR(255) NOT NULL,
  deleted_at  TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX customers_active_email_idx
  ON customers (email)
  WHERE deleted_at IS NULL;

Delete becomes update:

UPDATE customers SET deleted_at = now() WHERE id = 42;

Application scope:

class CustomerQuery:
    def active(self):
        return self.filter(deleted_at__isnull=True)

Centralize filtering — raw SQL and ORM bypasses leak deleted rows.

Unique constraint hell

User soft-deletes [email protected], tries re-register:

UNIQUE (email)  -- fails: deleted row still holds email

Fixes:

Partial unique index:

CREATE UNIQUE INDEX customers_email_active_uniq
  ON customers (email)
  WHERE deleted_at IS NULL;

Composite unique including deleted_at — allows one active + historical tombstones (messy).

Email mutation on delete — append +deleted_{id} (audit unfriendly).

Partial indexes are the cleanest Postgres/MySQL 8+ approach.

ORM "paranoid" mode

Sequelize, TypeORM, GORM offer paranoid deletes — auto-filter and set deletedAt. Danger: raw queries, reporting DB connections, and admin tools bypass ORM.

Global default scopes help; explicit withDeleted() for admin restores.

Undelete and retention

UPDATE customers SET deleted_at = NULL WHERE id = 42;

Define retention — soft-deleted rows hard-purged after 90 days via scheduled job. Without purge, tables bloat and compliance erasure never completes.

GDPR and soft delete tension

Soft delete ≠ erasure. DSAR requires anonymizing or hard-deleting PII columns:

UPDATE customers
SET email = 'erased-' || id || '@invalid.local',
    name = 'ERASED',
    deleted_at = coalesce(deleted_at, now())
WHERE id = 42;

Document which tables soft-delete vs anonymize vs hard-delete in privacy runbooks.

Foreign keys and cascades

Soft-deleting parent while children active breaks logical consistency. Options:

Prefer explicit cascade service over DB triggers hidden from developers.

Performance

Full table scans ignoring partial indexes when queries omit deleted_at IS NULL. Every index should be partial where engine supports it.

Archive cold soft-deleted rows to history table / cold storage — keeps hot table small.

Alternatives

Status enumactive | suspended | deleted instead of timestamp.

Separate archive table — move row on delete; active table stays clean.

Event sourcing — delete is event; projections rebuild state.

Hard delete + audit log — store deleted payload JSON in audit service, not OLTP table.

Pick based on query patterns and compliance, not convention.

Testing checklist

ORM bypass and the leak problem

The most common soft-delete bug isn't the delete itself — it's reads that bypass the ORM filter:

# ORM path — filtered correctly
Customer.objects.filter(email="[email protected]")  # excludes soft-deleted

# Raw SQL in reporting — LEAKS deleted data
db.execute("SELECT * FROM customers WHERE email = %s", ["[email protected]"])

# Admin tool with direct DB connection — LEAKS
# Analytics warehouse synced via CDC — INCLUDES deleted rows unless filtered

Mitigations:

Soft delete in event-driven architectures

CDC streams soft deletes as update events (deleted_at set), not delete events:

{
  "op": "u",
  "before": {"id": 42, "email": "[email protected]", "deleted_at": null},
  "after": {"id": 42, "email": "[email protected]", "deleted_at": "2025-07-15T10:00:00Z"}
}

Downstream consumers must handle this as a logical delete — remove from search index, invalidate cache, stop email campaigns. If they only process op: "d" hard deletes, soft-deleted records persist in derived systems forever.

Archive table pattern

For high-churn tables, move soft-deleted rows to archive instead of accumulating:

-- Scheduled job: move rows deleted > 30 days ago
INSERT INTO customers_archive SELECT * FROM customers
WHERE deleted_at < now() - interval '30 days';

DELETE FROM customers
WHERE deleted_at < now() - interval '30 days';

Active table stays small and fast. Archive table is read-only for audit/compliance. Hard delete from archive after retention period (1–7 years depending on regulation).

Failure modes

Production checklist

Add partial indexes on WHERE deleted_at IS NULL — soft-delete tables without them become full table scans on every query.

Resources

Frequently asked questions

What is soft delete?

Soft delete marks records as deleted — typically deleted_at timestamp or is_deleted flag — without physically removing rows. Applications filter active rows with WHERE deleted_at IS NULL. Enables undelete, audit trails, and referential integrity without cascade removes.

What problems do soft deletes cause?

Forgotten query filters leak deleted data; unique constraints conflict when re-creating rows with same natural key; tables grow unbounded; joins slow without partial indexes; GDPR right-to-erasure requires hard delete or anonymization anyway.

When should I use hard delete instead?

Use hard delete for regulated erasure requests, high-churn ephemeral data, and tables where retention policy mandates physical removal. Use soft delete when undo windows, audit requirements, or foreign key preservation justify retained rows with strict access controls.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →