A hard delete on a single record is forgivable. Someone notices quickly, because one thing changed and it was probably the thing they were just looking at. A hard delete on two hundred records selected through a bulk action is a different order of problem entirely, and it's the exact scenario where "we'll just restore from backup" turns out to be a much worse plan than it sounded like in the design meeting.

Photo by WinSon 5293 on Pexels
What a Soft Delete Actually Buys You
A soft delete marks a record as removed, usually with a boolean flag or a deleted-at timestamp, without running an actual delete statement against the database. The record stays exactly where it was, just excluded from normal queries. Reversing a soft delete is a single update statement. Reversing a hard delete requires a backup restore, and most teams' backup strategy is built around disaster recovery timelines measured in hours, not the "oh no, undo that" timeline of a few minutes that a bulk-action mistake actually needs.
For anything reachable through a bulk-select interface, this difference is the whole argument. The blast radius of a bulk mistake is large by definition, that's what makes bulk actions valuable in the first place, and soft deletes are the cheapest way to make that blast radius survivable.
The Real Costs, and Why They're Usually Worth Paying
Soft deletes aren't free. Every query against a soft-deletable table now needs a WHERE deleted_at IS NULL clause, or an equivalent, and forgetting it in even one query path is a real bug class, deleted records silently reappearing somewhere they shouldn't. Both PostgreSQL and MongoDB handle this cleanly through views, default query scopes, or ORM-level global filters, but the discipline of applying that filter consistently across the codebase falls on your team, not the database.
Storage is the other cost. Soft-deleted rows stick around until something actually purges them, and depending on your data retention obligations, that "something" needs to run on a schedule rather than never. This is where soft deletes intersect with actual policy, not just engineering convenience: how long is a deleted record kept before it's truly gone, and does that window satisfy whatever retention or right-to-erasure requirements apply to your data.
The Data Structure Behind It: Tombstones
The general concept of marking data as deleted rather than removing it outright predates any specific ORM or framework. Distributed and replicated data stores have used tombstone records for decades to handle exactly this problem: a delete needs to propagate correctly across replicas, and a tombstone, a marker saying "this was deleted, and here's when," is what makes that propagation reliable instead of ambiguous.
Understanding the tombstone pattern is useful even outside a distributed-systems context, because it clarifies what a soft delete is actually for: not hiding data, but recording a definitive, queryable fact that a deletion happened, so every system touching that data can agree on the current state instead of guessing from absence.
When a Hard Delete Is Still the Right Call
None of this means hard deletes should never happen. Sensitive data that genuinely needs to be gone, for legal, security, or storage-cost reasons, eventually needs an actual purge, and that purge is a hard delete by definition. The difference is timing and intent: a bulk-action-triggered soft delete followed by a scheduled, policy-driven hard delete weeks later is a fundamentally safer sequence than making every user-triggered bulk action an immediate hard delete.
Security and compliance guidance from bodies like the National Institute of Standards and Technology generally treats data retention and disposal as a deliberate, auditable process rather than something that happens as a side effect of a UI button. Building your delete flow around that model, soft delete now, audited purge later, on your own schedule, aligns naturally with that expectation instead of fighting it.
Handling Unique Constraints and Foreign Keys
One practical wrinkle soft deletes introduce is what happens to unique constraints. If a table enforces uniqueness on, say, an email address or a slug, and a soft-deleted row still occupies that value, a user trying to create a new record with the same email will hit a constraint violation even though the "old" record is supposed to be gone from their perspective. This trips up teams that add soft deletes to an existing schema without revisiting the constraints already in place.
The common fix is a partial or conditional unique index, one that only enforces uniqueness among rows where deleted_at IS NULL, so a soft-deleted record's old value frees up immediately for reuse while the deleted row itself keeps its original data intact for potential recovery. Both PostgreSQL and MongoDB support this kind of conditional indexing, though the exact syntax differs, and it's worth checking early rather than discovering the gap after a user complains they can't re-add a contact they just "deleted."
Foreign keys carry a similar wrinkle. A soft-deleted parent record with child rows that reference it needs those child rows to still resolve correctly if the parent gets restored, which argues against ever converting a soft delete into a cascading hard delete on related tables without a deliberate purge step that also decides what should happen to everything that pointed at the deleted record.
Auditing Who Deleted What, and When
A soft delete's timestamp tells you when a record was removed, but not by whom or in response to what action. For anything reachable through a bulk operation, pairing the soft-delete flag with a reference to the specific bulk action that triggered it, a batch ID, a user ID, and the filter or selection that produced the affected set, turns "we can restore this" into "we can also explain exactly what happened and why," which matters considerably more once more than one person could plausibly be responsible for a given deletion.
This audit trail is also what makes a "recently deleted" recovery view trustworthy rather than confusing. Showing a user forty records they can restore is only useful if they can also see enough context, when, by what action, as part of what batch, to tell whether restoring all forty is actually what they want, versus restoring a smaller subset.
Building This Into a Bulk Action Without Overcomplicating It
None of this requires a complicated system. A deleted_at column, a query scope or default filter that every read path respects, a scheduled job that purges records past the retention window, and a "recently deleted" view where users can see and restore their own bulk-deleted items covers the vast majority of cases. The last piece, the visible recovery view, is what turns the safety net from an engineering detail nobody sees into an actual feature users notice and trust.
For the rest of the bulk-action safety picture, confirmation dialogs, undo toasts, rate limiting the underlying batch, and accessible selection state, https://137foundry.com's longer guide on designing bulk actions that don't destroy data walks through how soft deletes fit alongside those other patterns rather than replacing them. Soft deletes solve the "can we get it back" problem. The rest of that guide covers the "did we even mean to delete it" problem that comes before, and the two are worth designing together rather than treating either one as a complete solution on its own. A soft delete without a confirmation step just means mistakes happen more often but recover cleanly; a confirmation step without a soft delete means the rare mistake that slips through has no cheap way back. Neither substitutes for the other.
Top comments (0)