Soft Deletes Sound Simple Until You Actually Need Them
Adding a deleted_at column is easy. Making soft deletes actually work with uniqueness rules, relationships, restores, reporting and GDPR is a different job entirely.

Soft deletes look like the easiest decision you'll make on a project. Instead of removing a row, you add a deleted_at column, set a timestamp instead of running a delete, and filter it out of your normal queries. Ten minutes of work. Then six months later someone asks "can we get that customer back?" and you realise the ten minute version never accounted for what happens next.
The bit everyone gets right
Marking a row as deleted instead of removing it is a good instinct. People change their minds, staff make mistakes, and being able to say "let me just restore that" instead of "I'm afraid it's gone" is worth having. Most frameworks make the basic mechanism trivial. Laravel's soft delete trait, for example, will happily hide deleted rows from your queries with almost no extra code.
The problem isn't the mechanism. It's everything that touches the mechanism once real data and real business rules get involved.
Uniqueness breaks first
Say a customer's email has to be unique. They close their account, you soft delete their row rather than remove it. Three months later they try to sign up again with the same email and get an error, because as far as the database is concerned that email is still taken. The row is "deleted" but it's still sitting there, still holding the constraint.
The usual fixes are messy in their own way. You can build the uniqueness check into your application logic instead of the database, but then you're relying on every code path remembering to check it. You can use a composite unique index that includes deleted_at, but that only works cleanly if your database treats null timestamps consistently in unique indexes, and not every database does. You can rewrite the email on delete (appending something like a timestamp) so it frees up the original, but now you've quietly changed data that someone might expect to restore intact.
None of these is wrong. But each one is a decision, not a default, and it needs making before you ship, not after a support ticket.
Relationships don't know the row is "gone"
A soft-deleted customer still has orders. A soft-deleted product still shows up on old invoices. If you're not careful, a report joins across tables and either silently drops the deleted parent (so historic orders lose their customer name) or shows it as if nothing happened (so a deleted product still appears purchasable somewhere it shouldn't).
You need an explicit answer, per relationship, for what happens when the parent is deleted. Should child records still resolve their parent's name for historic display? Should new records be blocked from referencing a deleted parent? Should a dashboard count include or exclude orders tied to deleted customers? These aren't technical questions really, they're business questions wearing a technical disguise, and they get skipped constantly because the soft delete "just worked" in testing with a handful of rows and no relationships to speak of.
Restoring is harder than deleting
Deleting one row is simple. Restoring it properly often isn't. If a customer was soft deleted along with their addresses, payment methods and subscriptions, do all of those come back together, or just the customer record? What happened to their data while they were "gone"? If another process ran cleanup jobs, sent cancellation emails, or released a reserved stock item on the assumption the customer wouldn't return, restoring the row doesn't undo any of that.
Soft delete without a tested restore path isn't really recoverability, it's just a slower way of losing the same information. If restoring matters to the business, it's worth treating as its own feature with its own test cases, not an assumed side effect of the delete flag existing.
Reporting quietly gets it wrong
Every count, every average, every "active customers this month" figure now needs to explicitly exclude soft-deleted rows, and it's easy to miss one. A raw SQL report written outside the application's normal query layer, an export script, a one-off query someone runs directly against the database. Each of those can happily include rows that everyone else has agreed are "gone", and the resulting numbers look plausible right up until someone cross-checks them and can't explain the gap.
GDPR wants something different entirely
This is the one that catches people out most. Soft delete is a recoverability mechanism. The data is still there, in full, sitting in your database. If someone submits a right-to-erasure request, a flag on the row doesn't satisfy it, because their personal data is still readable by anyone with database access.
Erasure and soft delete need to be treated as two separate mechanisms, not one. A soft delete says "hide this, but keep it available in case we need it back." An erasure request says "the underlying personal data actually has to go, even if the row itself has to stay for financial or audit reasons." In practice that often means keeping the row (because you still need it for tax records or dispute history) but overwriting the name, email, address and other identifying fields with anonymised values. That's a different code path to a normal soft delete, and it needs to exist even if it's rarely used.
This overlaps with how you handle deletion in your audit trail too. If deleting a record should leave a clear record of who deleted it, when, and ideally why, that's a deliberate design decision on top of the soft delete flag, not something a timestamp column gives you for free.
Decide the rules before you build the flag
Soft deletes are worth having on most systems that touch customers, orders or money. But the column is the easy 5%. The other 95% is deciding, entity by entity, what "deleted" actually means: what stays unique, what relationships still resolve, what restoring brings back, what reports should exclude, and what has to genuinely disappear regardless of the flag. Get those answers down before you write the migration, and the feature does what everyone assumes it already does.

