{"post":{"slug":"soft-delete-patterns-2026","title":"Soft Delete: The Flag That Infects Every Query","excerpt":"Adding a deleted_at column looks like the safe choice. Then every query needs a filter, every unique constraint breaks, and one forgotten WHERE clause leaks deleted data.","category":"code","date":"August 13, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">S</span>omeone deletes a customer record and later needs it back. The obvious fix is to not really delete it — add a `deleted_at` timestamp, set it instead of removing the row, and filter it out of queries.\n\nThe first part works. The second part is the problem, because \"filter it out of queries\" means every query, forever, written by everyone, including the one written at 6pm on a Friday by someone who did not know the convention.\n\n## What changed in 2026\n\n- **Retention obligations got stricter.** Data deletion requirements made \"we never actually delete anything\" a liability rather than a safe default.\n- **Partial indexes became the standard fix** for the unique constraint problem, which used to require awkward workarounds.\n- **Temporal patterns gained traction.** Separate history tables and system-versioning became more common as an alternative to flags.\n- **ORM support improved and stayed leaky.** Frameworks handle the common case and reliably miss raw queries, reporting tools, and direct database access.\n\n## The problems, concretely\n\n**Every query needs the filter.** This is the defining cost. Miss it once and deleted data appears somewhere it should not — a report, an export, an API response, an admin screen. The failure is silent and the blast radius depends entirely on where it happened.\n\n**Unique constraints stop working as intended.** A user deletes their account with email `a@example.com`, then signs up again with the same address. The unique constraint rejects it, because the deleted row still holds the value. You now have a support ticket and no obvious fix.\n\n**Foreign keys become misleading.** A child row can reference a soft-deleted parent, and the database sees nothing wrong. Referential integrity is intact by the database's definition and violated by yours.\n\n**Indexes and tables grow forever.** Deleted rows accumulate indefinitely, occupying space and slowing scans. Without a purge policy, a table of mostly-deleted rows is a table you are paying to scan.\n\n**Aggregates go quietly wrong.** A `COUNT(*)` or `SUM()` missing the filter produces a number that is plausible and wrong, which is worse than an error.\n\n## Making it safer\n\nIf you are going to soft delete, make the safe path the default rather than relying on everyone remembering.\n\n**Use a partial unique index.** Define uniqueness only over live rows: unique on `(email) WHERE deleted_at IS NULL`. Deleted rows no longer block reuse, and the constraint means what you intended — see [partial indexes](/blog/partial-indexes-explained-2026).\n\n**Expose a view, not the table.** Create a view that filters deleted rows and have application code read from it. Now forgetting the filter is impossible for anything using the view, and access to the raw table becomes a deliberate act.\n\n**Add partial indexes for the hot queries.** Indexes covering only live rows are smaller and cheaper, since most queries only ever want those.\n\n**Purge on a schedule.** Soft delete should be a recovery window, not permanent storage. Hard-delete rows after a defined period, which bounds growth and satisfies retention obligations.\n\n**Set the column, not a boolean.** A timestamp tells you when, which a boolean does not, and costs nothing extra.\n\n## When something else is better\n\n| Requirement | Better fit |\n| --- | --- |\n| Undo within a short window | Soft delete with scheduled purge |\n| Full history of changes | Separate audit or history table |\n| Regulatory retention | Archive table with explicit retention |\n| Who changed what, when | Audit log, not a flag |\n| Point-in-time reconstruction | System-versioned or temporal tables |\n\nThe distinction that matters most: **soft delete answers \"is this row live\", not \"what happened to it\".** A `deleted_at` column tells you a row was deleted at a time. It does not tell you who did it, why, or what the row looked like before an earlier edit.\n\nIf what you actually need is an audit trail — and often it is, once someone asks the question properly — a separate history table capturing changes serves that far better, and lets the main table delete rows normally. The main table stays clean, queries stay simple, and the history is queried deliberately when needed.\n\n## Common mistakes\n\n- **Relying on discipline for the filter.** Someone will forget; make it structural.\n- **Plain unique constraints.** Deleted rows block value reuse.\n- **No purge policy.** Unbounded growth and a retention problem.\n- **Assuming the ORM covers it.** Raw queries, reporting tools, and BI connections bypass it.\n- **Using it as an audit trail.** It records that something was deleted, not what happened.\n- **Cascading confusion.** Soft-deleting a parent while children remain live, with nothing enforcing consistency.\n- **A boolean instead of a timestamp.** Loses information for no saving.\n\n## FAQ\n\n**Is soft delete an anti-pattern?**\nNo, it is a trade with costs people underestimate. For a genuine short undo window it is reasonable. As permanent storage substituting for an audit trail, it usually goes badly.\n\n**How do I add it to an existing system safely?**\nColumn, partial unique indexes, then a view — and migrate readers to the view before anything starts writing the flag. Adding the column first and hoping queries get updated is how leaks happen.\n\n**What about cascading?**\nDecide deliberately and enforce it. Soft-deleting a parent while children remain live creates orphans the database considers valid. Either cascade the flag or handle it in queries; do not leave it ambiguous.\n\n**Does it interact with row-level security?**\nYes, and usefully. A security policy that filters deleted rows enforces it at the database level for every connection, which is stronger than a view anyone can read around.\n\n## Where to go next\n\nFor the unique constraint fix, read [partial indexes](/blog/partial-indexes-explained-2026). For managing data lifecycle at the storage level instead, [table partitioning](/blog/table-partitioning-explained-2026), and for the version churn deleted rows contribute to, [MVCC explained](/blog/mvcc-explained-2026).","hue":302,"metaTitle":"Soft Delete: The Flag That Infects Every Query","metaDescription":"Why soft delete breaks unique constraints and foreign keys, how a forgotten filter leaks data, and when an audit table is the better answer.","keywords":["soft delete","deleted_at column","logical delete pattern","unique constraint soft delete","audit table vs soft delete","data retention database","query filter leakage","database design 2026"],"tldr":["**Every query must remember the filter** — and the one that forgets is how deleted records reach users.","**Unique constraints break immediately** — a deleted row still occupies the unique value unless you make the constraint partial.","**Foreign keys still point at deleted rows** — referential integrity does not know your flag means anything.","**A view or a partial index enforces it better than discipline** — make the safe path the default path.","**Skip:** soft delete when you actually need an audit trail. A separate history table answers \"what happened\" far better than a flag."],"readTime":"4 min read","wordCount":966},"related":[{"slug":"database-design-patterns-2026","title":"Database design patterns in 2026: practical patterns that scale","excerpt":"The database patterns that survive contact with real traffic are boring and well-known. Here is the 2026 practical guide — indexes, denormalization, soft deletes, audit trails, and the trade-offs.","category":"code","date":"February 13, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":152},{"slug":"enum-vs-lookup-table-2026","title":"Enum vs Lookup Table: Where Should Your Fixed Values Live?","excerpt":"A status column with five possible values can be a database enum, a lookup table, or a check constraint. The right answer depends almost entirely on who needs to change the list.","category":"code","date":"January 9, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":315},{"slug":"partial-indexes-explained-2026","title":"Partial Indexes: Indexing Only the Rows That Matter","excerpt":"Most queries against a large table only ever touch a small slice of it. A partial index covers just that slice, and is a fraction of the size and cost.","category":"code","date":"August 12, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":316}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"senior-bonus-deduction-2026","title":"The Senior Bonus Deduction: Who Gets It and What It Is Worth","excerpt":"A deduction for taxpayers 65 and older that stacks on top of the standard deduction and the existing age-based addition. Here is who qualifies and where it phases out.","category":"finance","date":"August 13, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":280},"older":{"slug":"special-needs-trust-2026","title":"Special Needs Trusts: Providing Without Disqualifying","excerpt":"Leaving money directly to a person receiving means-tested benefits can disqualify them. A properly drafted trust provides support without that consequence.","category":"finance","date":"August 13, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":320}}