VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
The Basic Pattern
Add a nullable timestamp.NULL means live; a timestamp means deleted, and records when:
deleted boolean: it costs the same, and it answers “when” for free, which the support ticket will ask.
Problem 1: Unique Constraints
A unique index onemail blocks re-registration forever: the soft-deleted row still occupies the value.
NULLs, so deleted rows never collide, while two live rows with the same email still fail. See Generated columns for the mechanics.
Problem 2: Every Query Must Remember
One forgottenWHERE deleted_at IS NULL and deleted data leaks into a report or an API response. Two mitigations:
- A view for the live subset. Point read paths at the view and the filter cannot be forgotten:
- ORM scopes. Most ORMs can apply the filter globally (Rails
default_scope, Django managers, Prisma middleware). Turn it on in one place instead of auditing every query.
ON DELETE CASCADE never fires. If children should disappear with the parent, the application (or a trigger) has to cascade the deleted_at itself.
When Not to Soft Delete
- Regulatory erasure. A GDPR deletion request means the data is gone, not hidden. Soft-deleted rows still count as personal data.
- High-churn tables. Sessions, queue jobs, and logs deleted by the millions just bloat the table and every index on it.
- Data nobody will restore. If there is no undo story, the filter tax buys nothing.
deleted_at is older than the retention window:
See also
- Generated columns — the mechanism behind the unique-constraint fix
- Views — a live-rows view that cannot forget the filter
- Foreign keys — why cascades don’t fire on soft deletes

