Soft delete

Marking a row as deleted instead of removing it, so it disappears from the app but can still be restored.

hide it but keep it recoverabletrash can instead of shredderdeleted_at columnmark as deleted instead of deletingarchive instead of deletesoft-delete flagis_deleted booleanundo delete

See it

Live demo coming soon

What it is

A soft delete flips a flag instead of running DELETE. The row stays in the table with a deleted_at timestamp (nullable timestamps beat booleans: you get 'when' for free), and every query adds WHERE deleted_at IS NULL. Users see the thing vanish; you can put it back. Rails calls this pattern discard or paranoia, and most ORMs have some flavor of it.

Reach for it when deletion is user-facing and reversible (a trash bin with 30-day recovery), when the record is referenced by things you cannot orphan (an invoice line pointing at a deleted product), or when you need the history for an audit log. If none of those apply, a real DELETE is simpler and cheaper.

The gotcha is that the filter is now your problem forever: one query written without WHERE deleted_at IS NULL leaks deleted rows back into the UI. Enforce it in one place rather than by remembering, and know how strong that place is: an ORM default scope covers code that goes through the ORM and nothing else, so raw SQL, a BI tool, or a console session walks straight past it. When the hiding has to actually hold, put it in the database with a filtered view or a row-level security policy. Then the second surprise: unique constraints still see the dead rows, so a user cannot re-register a deleted email until you switch to a partial unique index (WHERE deleted_at IS NULL). And soft delete is not erasure, so a GDPR deletion request means a real DELETE or an anonymization pass, not a flag.

Ask AI for it

Add soft delete to [TABLE]. Use a nullable deleted_at timestamptz column (not a boolean) plus a deleted_by user reference. Do not add a partial index on (id): the primary key already finds a row by id. Instead add partial indexes that match the queries the app actually runs on active rows, for example (workspace_id, created_at DESC) WHERE deleted_at IS NULL. Replace destructive deletes with an update that sets deleted_at = now(). For enforcing the filter, be explicit about the strength I am getting: an ORM default scope is a convention that a raw query, a reporting tool, or a psql session can bypass, so if the hiding has to hold at the database, read through a view that filters deleted rows or add a row-level security policy, and use the ORM scope only as ergonomics on top. Convert existing unique constraints into partial unique indexes with WHERE deleted_at IS NULL so a soft-deleted row does not block re-creating the same value. Add restore and permanent-purge operations, a scheduled job that hard-deletes rows soft-deleted more than 30 days ago, and tests asserting that soft-deleted rows never appear in list, search, or count queries.

You might have meant

audit logunique constraintrow level securitydatabase viewindex