✏️ Explanatory Question

Soft delete vs hard delete — correct implementation, UNIQUE constraint pitfalls, and the forgotten-filter bug

👁 6 Views
📘 Detailed Answer
🟢 Easy
💡

Answer with Explanation

112

Soft delete vs hard delete — correct implementation and its hidden traps

Level: Hard — Widely used but full of subtle bugs; the UNIQUE-constraint clash and forgotten filter are classic production issues.

Scenario: Your app "deletes" users but must keep their data for audits and be able to restore them. You add a deleted_at column (soft delete). Then two bugs appear: (1) a "deleted" user's email can't be reused for a new signup, and (2) a report accidentally counts deleted users. Explain both and how to do soft delete correctly.

Hard Delete vs Soft Delete

  • Hard delete: DELETE FROM users — the row is physically gone. Simple, but unrecoverable and loses history.
  • Soft delete: Set a deleted_at timestamp (or is_deleted flag). The row stays; queries filter it out. Enables restore, audit, and referential history.

Schema & Sample Data

CREATE TABLE users (
    id         BIGINT AUTO_INCREMENT PRIMARY KEY,
    email      VARCHAR(255) NOT NULL,
    name       VARCHAR(100),
    deleted_at TIMESTAMP NULL DEFAULT NULL   -- NULL = active, timestamp = deleted
);
idemaildeleted_atState
1rumman@x.comNULLActive
2krushna@x.com2026-07-01 10:00Soft-deleted

Bug 1 — The UNIQUE Constraint Clash

You put UNIQUE(email) so no two users share an email. But Krushna is soft-deleted (still physically in the table). When someone tries to sign up again with krushna@x.com, the UNIQUE constraint rejects it — even though that user is "deleted." A dead row is blocking a live signup.

Fix for Bug 1 — Partial Uniqueness

-- PROBLEM: plain UNIQUE blocks reusing a soft-deleted email
-- ALTER TABLE users ADD UNIQUE (email);   -- too strict

-- FIX (MySQL 8.0+): functional index that only enforces uniqueness on ACTIVE rows
-- Deleted rows get NULL in the expression, and NULLs don't collide in UNIQUE
ALTER TABLE users
  ADD UNIQUE INDEX uq_active_email (
    (CASE WHEN deleted_at IS NULL THEN email END)
  );
-- Now: only ONE active row per email, but any number of deleted ones

-- FIX (portable): include deleted_at in the unique key
-- ALTER TABLE users ADD UNIQUE (email, deleted_at);
-- Caveat: multiple NULLs are allowed, so this permits repeated ACTIVE emails
-- unless you use a sentinel value instead of NULL for deleted_at.

Bug 2 — The Forgotten Filter

Every query must add WHERE deleted_at IS NULL. Forget it once, and deleted rows leak into reports, lists, and counts. This is the #1 soft-delete bug — it's easy to miss and hard to spot.

Fix for Bug 2 — Views & Consistent Access

-- WRONG: forgets the filter -> counts deleted users
SELECT COUNT(*) FROM users;                    -- includes Krushna!

-- RIGHT: always filter
SELECT COUNT(*) FROM users WHERE deleted_at IS NULL;

-- SAFER: expose a view that hides deleted rows by default
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;

-- App queries the view; the filter can never be forgotten
SELECT COUNT(*) FROM active_users;

-- The delete & restore operations
UPDATE users SET deleted_at = NOW()  WHERE id = 5;   -- soft delete
UPDATE users SET deleted_at = NULL   WHERE id = 5;   -- restore

Soft Delete Trade-offs

ProCon
Recoverable / restoreEvery query needs the filter
Keeps audit historyUNIQUE constraints get tricky
Preserves FK referencesTable grows; indexes bloat
No cascade-delete data lossMust periodically purge old rows
Senior best practices: Use a nullable deleted_at (not a boolean — you get the deletion time for free), enforce active-only partial unique indexes, access data through views to avoid forgotten filters, and add a purge job to hard-delete very old soft-deleted rows so the table doesn't grow forever.

Interviewer follow-up: "Soft-deleted rows are bloating your hot table and slowing queries. What do you do?" → Options: (1) add deleted_at IS NULL to your indexes (or use partial indexes) so active-row lookups stay fast, (2) partition by deleted/active, or (3) periodically move old soft-deleted rows to an archive table, keeping the hot table lean.