How 2 Soft Delete.

Patterns and best practices for data retention.

Mateus Henrique Bosquetti
by Mateus Henrique Bosquetti
7 min read

In modern systems, data restoration is a real demand, whether due to user error, auditing, or support. The goal of this article is to present the concept of Soft Delete, along with its main strategies in an interactive way, so you can test and choose the best approach for your next project.

The Problem

Imagine a common scenario where a user deletes an important record by mistake. It could be an admin who clicked the wrong button, a client removing data that later becomes critical, or someone cleaning up what they thought was temporary. When there is no strategy for retaining or recovering this data, that simple mistake turns into a real crisis, where you have to stop everything, restore backups in an isolated environment, locate records manually, and reinsert whatever is possible, with the risk of losing everything created after the last database snapshot.

This scenario is a perfect example of Hard Delete in critical tables. Hard Delete is the physical removal of the record, usually done with a direct DELETE command on the table. In short, the data is truly removed from the database and disk. There is no immediate rollback, no audit trail by default, and without external backup, there is no way to recover it.

Here is an example of Hard Delete:

Hard Delete

Hard delete example in a task table

Step

Select a row

tasks

id
name
urgency
11Send financial reportHIGH
22Review API documentationMEDIUM
33Update project dependenciesLOW

SQL Console

readonly
>
SELECT * FROM tasks
>
3 rows retrieved in 1301 ms (execution: 11 ms, fetching: 1290 ms)

This approach is dangerous for critical tables, but it is the ideal (and native) one for secondary data, temporary records, or quick registrations that do not impact the business.

The Solution

Soft Delete treats deletion as a logical state, not a physical one. For the user, the data is gone, but for the database, it was only marked as hidden. There are two main ways to implement this, which is what we call Soft Delete Patterns.

Logical Delete

The first Soft Delete Pattern is Logical Delete, with the simplest and most common approach. It consists of adding a column in the table itself to signal the record state.

Follow the steps below to understand a Logical Delete flow

Logical Delete Pattern

Logical Delete example in a task table

Step

Select a row

tasks

id
name
urgency
archived_at
11Send financial reportHIGHnull
22Review API documentationMEDIUMnull
33Update project dependenciesLOWnull

SQL Console

readonly
>
SELECT * FROM tasks
>
3 rows retrieved in 1301 ms (execution: 11 ms, fetching: 1290 ms)

As we can see, instead of physically removing the record, we only update the 'archived_at' column. This way, we can distinguish between active records and those that have been archived or deleted.

To implement Logical Delete, just add a new column to your table:

SQL SCRIPT

readonly
ALTER TABLE tasks
ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL;

This field can be a boolean or a timestamp. The important thing is that it clearly indicates whether the record is active or deleted.

The problem appears at scale. Millions of deleted records keep occupying space and dirtying indexes in the main table.

Here is a common challenge of Logical Delete:

The Zombie Table

Example of the bloated table problem with logical delete

Step

Run a select to display all active tasks

tasks

id
name
urgency
archived_at
11Send financial reportHIGH2026-02-19T10:22:32.123Z
22Review API documentationMEDIUMnull
33Update project dependenciesLOW2026-02-05T17:51:12.332Z
1-3 of 4173289

SQL Console

readonly
>
SELECT * FROM tasks
>
4173289 rows retrieved in 4580 ms (execution: 780 ms, fetching: 3800 ms)

99% of the time you only care about active records, yet you're forced to carry the weight of millions of dead rows and remember to filter them in every single query.

Shadow Table

The second Soft Delete Pattern is Shadow Table, which is a more complex solution to solve main table bloat. Here, instead of marking the record, we move it to a secondary archive table.

Follow the steps below to understand the Shadow Table flow

Shadow Table Pattern

Shadow table pattern example for data recovery

Step

Select a row

tasks

id
name
urgency
11Send financial reportHIGH
22Review API documentationMEDIUM
33Update project dependenciesLOW

archives

id
table_name
record_id
data
archived_at
caused_by_table
caused_by_id
No rows selected

SQL Console

readonly
>
SELECT * FROM tasks
>
3 rows retrieved in 1301 ms (execution: 11 ms, fetching: 1290 ms)

As we can see, when a record is deleted from the 'tasks' table, it is automatically moved to the 'archives' table via a database trigger.

The examples below use PostgreSQL syntax and features (like Triggers and JSONB). Some modifications may be necessary for other database systems.

To implement Shadow Table, complexity increases. First, we must create the 'archives' table in our database:

SQL SCRIPT

readonly
CREATE TABLE archives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id JSONB NOT NULL,
data JSONB NOT NULL,
caused_by_table TEXT NOT NULL,
caused_by_id JSONB NOT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

Now we must create the function that will handle the archiving logic:

How to implement Shadow Table

readonly
CREATE OR REPLACE FUNCTION fn_shadow_archive()
RETURNS TRIGGER AS $$
DECLARE
v_cause_table TEXT;
v_cause_id_text TEXT;
BEGIN
v_cause_table := current_setting('app.current_deleter_table', true);
v_cause_id_text := current_setting('app.current_deleter_id', true);

IF (v_cause_table IS NULL OR v_cause_table = '') THEN
v_cause_table := TG_TABLE_NAME;
v_cause_id_text := OLD.id::text;

PERFORM set_config('app.current_deleter_table', v_cause_table, true);
PERFORM set_config('app.current_deleter_id', v_cause_id_text, true);
END IF;

INSERT INTO archives (
table_name,
record_id,
data,
caused_by_table,
caused_by_id
)
VALUES (
TG_TABLE_NAME,
jsonb_build_object('id', OLD.id),
to_jsonb(OLD),
v_cause_table,
jsonb_build_object('id', v_cause_id_text)
);

RETURN OLD;
END;
$$ LANGUAGE plpgsql;

This function is responsible for taking the deleted record and inserting it into the 'archives' table.

Now we just need to create triggers for critical tables. This way, they are protected against Hard Delete. In the example below, the Shadow Table pattern is applied to three tables: users, establishments, and appointments.

How to create Shadow Table Triggers

readonly
CREATE TRIGGER trg_archive_users BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION fn_shadow_archive();
CREATE TRIGGER trg_archive_establishments BEFORE DELETE ON establishments FOR EACH ROW EXECUTE FUNCTION fn_shadow_archive();
CREATE TRIGGER trg_archive_appointments BEFORE DELETE ON appointments FOR EACH ROW EXECUTE FUNCTION fn_shadow_archive();

Nothing is free. While Logical Delete requires only an UPDATE, restoring data in a Shadow Table requires more complex INSERT INTO and SELECT queries, especially when foreign key relationships are involved.

In the example below, we have 3 examples of situations where you must recover deleted data.

Restoring Data

Example of restoring archived data

archives

id
table_name
record_id
data
archived_at
caused_by_table
caused_by_id
12a8f5d9c-5f74-4d0f-8f61-1f45e6d0a101establishments{"id":"est-500"}{"id":"est-500","name":"Downtown Barbershop","owner_id":"user-owner-1","created_at":"2026-01-10T10:00:00.000Z"}2026-02-23T10:00:00.000Zestablishments{"id":"est-500"}
29d0f7b6a-2f43-4b23-a6df-3a4db44cf102appointments{"user_id":"user-200","establishment_id":"est-500","scheduled_at":"2026-03-01T09:00:00.000Z"}{"user_id":"user-200","establishment_id":"est-500","scheduled_at":"2026-03-01T09:00:00.000Z","service":"Haircut","status":"CONFIRMED"}2026-02-23T10:00:00.050Zestablishments{"id":"est-500"}
37bb2a10e-61b4-46e9-9f83-58d39f2aa103appointments{"user_id":"user-201","establishment_id":"est-500","scheduled_at":"2026-03-01T10:00:00.000Z"}{"user_id":"user-201","establishment_id":"est-500","scheduled_at":"2026-03-01T10:00:00.000Z","service":"Beard Trim","status":"PENDING"}2026-02-23T10:00:00.080Zestablishments{"id":"est-500"}
445d98f8a-8cf9-4f35-8f69-c9ec3f5b4104users{"id":"user-300"}{"id":"user-300","name":"Fernanda Lima","email":"fernanda@email.com","role":"CUSTOMER","created_at":"2026-01-15T12:00:00.000Z"}2026-02-23T11:00:00.000Zusers{"id":"user-300"}
5c0f57e2b-2d9f-4d8c-8f2e-98f703a6b105appointments{"user_id":"user-300","establishment_id":"est-800","scheduled_at":"2026-03-05T14:00:00.000Z"}{"user_id":"user-300","establishment_id":"est-800","scheduled_at":"2026-03-05T14:00:00.000Z","service":"Manicure","status":"CONFIRMED"}2026-02-23T11:00:00.040Zusers{"id":"user-300"}
6ad3bcae9-7c31-4ceb-a20d-e4f952107106appointments{"user_id":"user-300","establishment_id":"est-801","scheduled_at":"2026-03-06T16:00:00.000Z"}{"user_id":"user-300","establishment_id":"est-801","scheduled_at":"2026-03-06T16:00:00.000Z","service":"Pedicure","status":"PENDING"}2026-02-23T11:00:00.070Zusers{"id":"user-300"}
7f6a1db8e-34a9-4d3d-b7f3-27a8c1de7107appointments{"user_id":"user-555","establishment_id":"est-500","scheduled_at":"2026-03-10T18:00:00.000Z"}{"user_id":"user-555","establishment_id":"est-500","scheduled_at":"2026-03-10T18:00:00.000Z","service":"Massage","status":"CANCELLED_BY_USER"}2026-02-23T12:00:00.000Zappointments{"user_id":"user-555","establishment_id":"est-500","scheduled_at":"2026-03-10T18:00:00.000Z"}
1-7 of 142

Mateus (mateus@email.com) contacted support saying he accidentally deleted the 'Downtown Barbershop' establishment and needs to recover the record.

Customer Fernanda Lima requested recovery of an account that was accidentally removed during a manual data cleanup.

Support received a request to restore an appointment canceled by mistake, while keeping the history for auditing.

SQL Console

readonly
>
SELECT * FROM archives LIMIT 7;
>
7 rows retrieved in 1432 ms (execution: 12 ms, fetching: 1420 ms)

The trade-off is direct: recovering data here is not a simple UPDATE. It usually involves gathering context, rebuilding dependencies, and executing a sequence of queries with extra care.

The 3 SELECTs make it clear why modeling record_id and caused_by_id as JSONB is worth it. It creates room for composite key records without forcing the archives schema to change for every new case. The caused_by_table and caused_by_id columns also greatly improve cascade delete traceability, because they make the event source explicit.

If your system requires frequent data recovery, consider using dedicated shadow tables for each entity. This preserves original data types and avoids JSONB, ensuring higher integrity and faster access speeds.

Conclusion: Which one to choose?

In practice, the best deletion strategy depends heavily on the product stage and architecture maturity level. There is no single answer, there is a context-aware choice.

For POCs and early stages, Logical Delete usually makes more sense. It provides safety for data recovery with quick implementation, low friction, and without introducing project complexity too early.

For robust MVPs, pilots, and production-ready products, Shadow Table can be much more advantageous. Despite the higher implementation cost, it helps keep the main table cleaner, improves performance predictability, and better organizes the archiving strategy.

There are other soft delete patterns beyond the ones mentioned here. In this article, I brought the most well-known ones that I have used in practice, precisely to share decisions I tested in real scenarios.

Honorable mention to Alex Buchanan, creator of the blog atlas9.dev, who inspired me to build this article.