By WebNest Studio Engineering Team
Database Design Tutorial
Soft Delete Pattern in Database Design
A soft delete marks a row as deleted without physically removing it from the table, typically through an is_deleted boolean or a deleted_at timestamp column, so the data still exists in the database but is excluded from normal application queries. This is a deliberate alternative to a hard delete (an actual DELETE statement that permanently removes the row), and choosing between the two is a real design decision with meaningful trade-offs, not just a style preference.
Soft delete is common in production systems because deleted data is very often needed again — for recovery from a mistaken deletion, for audit and compliance requirements, for historical reporting, or because other rows still reference the "deleted" row through foreign keys and cannot simply lose that reference.
Why it matters
Users delete things by mistake, and businesses often must keep records that have been "removed". Marking a row as deleted instead of erasing it allows recovery and preserves history. The pattern has costs, though: every query has to remember to exclude deleted rows, and unique constraints need extra thought.
The deleted_at Pattern
A nullable deleted_at TIMESTAMP column is generally preferable to a plain is_deleted boolean, because it captures both the fact of deletion and exactly when it happened, at no extra storage cost. Every query that should only see "live" rows adds a WHERE deleted_at IS NULL clause (often wrapped in a view or an ORM default scope so it isn't forgotten), and "deleting" a row becomes an UPDATE that sets deleted_at to the current timestamp instead of a DELETE.
Trade-offs vs. Hard Delete
Soft delete preserves recoverability, audit history, and referential integrity for rows still referenced elsewhere, but it comes at a real cost: every query in the application must consistently remember to filter out soft-deleted rows, unique constraints become more complicated (a deleted user's email should probably be reusable by a new signup, but a plain UNIQUE constraint on email would block that), and the table grows indefinitely since nothing is ever actually removed. Hard delete is simpler and keeps tables smaller, but it is permanent, breaks any foreign keys still pointing at the row (unless cascaded), and destroys any audit trail of what used to exist.
- Choose soft delete when data must be recoverable, when compliance or audit rules require history, or when other tables reference the row.
- Choose hard delete for genuinely transient or sensitive data (e.g. expired session tokens, data under a legal "right to erasure" requirement) where retention itself is the risk.
- Hybrid approach: soft delete immediately, then a scheduled job permanently purges rows past a defined retention period.
Handling Uniqueness and Foreign Keys with Soft Delete
A plain UNIQUE constraint on a column like email breaks the moment a soft-deleted row exists with that email, since the constraint doesn't know the row is "gone." Many databases support a partial unique index (UNIQUE ... WHERE deleted_at IS NULL) that enforces uniqueness only among live rows, which is the correct fix. Foreign keys pointing at soft-deleted rows also need a deliberate policy: the application typically must still be able to display an order placed by a since-deleted customer, so the customer record is kept (soft-deleted) rather than hard-deleted out from under existing orders.
Example
Soft delete with a partial unique index so the email can be reused after deletion
CREATE TABLE customers (
id BIGINT PRIMARY KEY,
full_name VARCHAR(150) NOT NULL,
email VARCHAR(255) NOT NULL,
deleted_at TIMESTAMP NULL
);
-- Enforce uniqueness only among live (non-deleted) rows
CREATE UNIQUE INDEX uq_customers_email_live
ON customers (email)
WHERE deleted_at IS NULL;
-- "Deleting" a customer is an UPDATE, not a DELETE
UPDATE customers SET deleted_at = CURRENT_TIMESTAMP WHERE id = 42;
-- Every normal application query filters out soft-deleted rows
SELECT id, full_name, email FROM customers WHERE deleted_at IS NULL;
Customer id 42 remains physically in the table (its orders and other
references stay valid), but disappears from normal application queries.
Because the unique index only covers rows where deleted_at IS NULL, a
new customer can sign up again later with the same email address that
the deleted account used, without a constraint violation.
Common Mistakes
- Adding an is_deleted or deleted_at column without a partial unique index, then discovering that a soft-deleted row's unique email/username permanently blocks new signups with the same value.
- Forgetting to add "WHERE deleted_at IS NULL" in some queries or reports, causing soft-deleted rows to reappear inconsistently across the application.
- Choosing soft delete for every table by default, including high-volume transient data, causing tables to grow indefinitely and slowing down queries and indexes over time.
- Hard-deleting a row that other tables still reference via foreign key, either causing a constraint violation or an unwanted cascade that removes historical data.
Exercise
Add soft delete to the users table: a deleted_at column, and a unique index on email that applies only to rows that are not deleted, so that the address can be reused. Soft-delete Asha, then register a new user with the same email. Count the active users, and then all rows.
Expected output: active, 1, total, 2 (one per line)
-- TODO: users (id, email, deleted_at)
-- TODO: a unique index on email that covers only rows where deleted_at IS NULL
INSERT INTO users (id, email) VALUES (1, 'asha@example.com');
-- TODO: soft-delete user 1 by setting deleted_at to the current time
INSERT INTO users (id, email) VALUES (2, 'asha@example.com');
SELECT COUNT(*) AS active FROM users WHERE deleted_at IS NULL;
SELECT COUNT(*) AS total FROM users;
Show hints
- A partial index has a
WHEREclause:CREATE UNIQUE INDEX ... ON users (email) WHERE deleted_at IS NULL. - A soft delete is an
UPDATE, not aDELETE.
Show solution
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX users_email_active ON users (email) WHERE deleted_at IS NULL;
INSERT INTO users (id, email) VALUES (1, 'asha@example.com');
UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE id = 1;
INSERT INTO users (id, email) VALUES (2, 'asha@example.com');
SELECT COUNT(*) AS active FROM users WHERE deleted_at IS NULL;
SELECT COUNT(*) AS total FROM users;
Quick quiz
1. What is a soft delete?
- A. Deleting a row slowly
- B. Deleting only some columns
- C. Marking a row as deleted, for example with a deleted_at timestamp, without removing it
- D. Deleting from a backup
Show answer
C. The row stays in the table and can be restored.
2. What is the main ongoing cost of soft delete?
- A. Every query must exclude the deleted rows, and forgetting to do so shows deleted data
- B. Rows can never be read again
- C. Indexes stop working
- D. Transactions are disabled
Show answer
A. A view of the active rows, or a default filter in the ORM, reduces the risk.
3. Why does a plain UNIQUE constraint on email cause trouble with soft delete?
- A. It is slow
- B. A soft-deleted row still holds the email, so the address cannot be registered again
- C. It allows duplicates
- D. It deletes the row
Show answer
B. A partial unique index restricted to the rows that are not deleted solves it.
Interview questions
What are the trade-offs of soft delete compared with hard delete?
Show answer
Soft delete allows recovery from mistakes, keeps history and references intact, and supports audits. Its costs are that every query must filter out deleted rows, tables and indexes grow with data nobody sees, unique constraints need partial indexes, and foreign keys no longer prevent references to a "deleted" parent. Hard delete is simpler and keeps tables small, but the data is gone.
How does soft delete interact with privacy laws such as the GDPR?
Show answer
A soft-deleted row still contains the personal data, so it does not satisfy a request for erasure. When a person exercises that right, the data has to be truly removed or irreversibly anonymised, including in backups according to the retention policy. A common design soft-deletes first for a short recovery period and then permanently deletes or anonymises the row with a scheduled job.
Key Points to Remember
- Soft delete marks a row as deleted (commonly via a nullable deleted_at timestamp) instead of physically removing it, keeping the data recoverable and preserving references from other tables.
- Every application query must consistently exclude soft-deleted rows, usually enforced through a view or ORM default scope so it is never forgotten.
- Unique constraints on soft-deletable tables should typically be partial indexes scoped to live rows, so values can be reused after deletion.
- Soft delete is not free — it requires an explicit retention/purge strategy or tables grow indefinitely, and hard delete remains appropriate for transient or legally-required-to-erase data.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering