VibeWeek
Home/Grow/Soft Delete vs Hard Delete: When to Use Each, How to Implement Both, and the Edge Cases That Bite

Soft Delete vs Hard Delete: When to Use Each, How to Implement Both, and the Edge Cases That Bite

⬅️ Day 6: Grow Overview

If you're running a SaaS in 2026, the deletion strategy you pick early determines whether undo / GDPR / audit / debugging work in year 3. Most founders default to one extreme — either hard-delete everything (data loss; no undo; angry customers) or soft-delete everything (massive tables; "deleted" rows showing up in queries; GDPR violation when "deleted" emails are still in your DB three years later). Neither extreme is right.

A working delete strategy answers: which entities soft-delete vs hard-delete, how does the application code consistently filter deleted rows, what happens to dependent data (cascade vs orphan), and how does GDPR-compliant true deletion fit. Done well, deletion is invisible — undo works, queries stay fast, compliance is satisfied. Done badly, "we deleted that" turns out to mean "we hid it but it's still searchable in 47 places."

This guide is the implementation playbook for soft delete and hard delete — the entity-by-entity decision, schema patterns, query discipline, GDPR-compliant true delete, and the edge cases that compound silently.

The Core Distinction

Get the model straight before writing any code.

Help me understand soft vs hard delete.

The two patterns:

**Hard delete**:

```sql
DELETE FROM users WHERE id = $1;
  • Row is removed from database
  • All foreign keys ON DELETE behavior fires (CASCADE / RESTRICT / SET NULL)
  • Storage reclaimed (eventually, after vacuum)
  • Cannot be undone (without backups)

Soft delete:

UPDATE users SET deleted_at = now() WHERE id = $1;
  • Row remains in database
  • Add deleted_at (or deleted_by / is_deleted) column
  • Application code filters out WHERE deleted_at IS NULL
  • Can be undone (UPDATE ... SET deleted_at = NULL)
  • Storage grows over time

The implications:

Aspect Hard Soft
Undo support No Yes
Storage growth None Forever
Query complexity None Filter every query
Foreign-key handling Cascade / restrict Application logic
GDPR-compliance Easy Requires extra work
Audit trail Lost Preserved
Performance Fast Slower over time
Bug surface Low Higher (forgot filter)

The "what happens after delete?" test:

For each entity type, ask:

  • Does the customer expect undo? (most do)
  • Is audit history needed? (often yes for B2B)
  • Is GDPR true-erasure required? (often yes for personal data)
  • Is dependent data affected? (orders depend on users)

These determine soft vs hard.

For my system:

  • List of entities (users, projects, tasks, comments, etc.)
  • Customer expectations for each
  • Compliance requirements

Output:

  1. The entity inventory
  2. The expected-behavior matrix
  3. The soft-vs-hard decision per entity

The biggest unforced error: **picking one strategy globally.** "All deletes are soft" → GDPR fines for un-erased emails. "All deletes are hard" → customer accidentally deletes 3 months of work, no recovery, churns. The fix: per-entity decision; document why; consistent application.

## The Per-Entity Decision

Most products need a mix. Use this framework.

Help me decide per entity.

The four categories:

1. Soft delete (most user-facing entities)

Use for:

  • Documents / files / projects (user expects undo / trash)
  • Tasks / items (similar)
  • Comments / messages (audit; thread context)
  • Workflows / configurations (don''t lose customer work)

Why:

  • Customer expects undo
  • Accidental deletion is common
  • Audit / context preserved
  • Re-deletion (true purge) on retention schedule

2. Hard delete (where soft adds nothing)

Use for:

  • Sessions / tokens (security; no need to keep)
  • Cache entries
  • Temporary import staging tables
  • Rate-limit counters
  • Internal worker queue jobs (after completion)

Why:

  • No customer expectation of recovery
  • Security risk to keep
  • Pure overhead

3. Hard delete with audit log (compliance-critical)

Use for:

  • User PII when GDPR right-to-erasure invoked
  • Sensitive financial / health data after retention period
  • Anything where regulator requires actual deletion

Implementation:

  • Hard-delete from primary tables
  • Write to immutable audit log (per audit-logs-chat)
  • Audit log doesn''t contain the deleted PII (only metadata)

Why:

  • GDPR / HIPAA / etc. require true deletion
  • Compliance evidence needed
  • Forensics retained without PII

4. Soft delete then hard delete on schedule

Use for:

Why:

  • Best of both worlds
  • Customer-friendly (undo period)
  • Compliance-friendly (eventually true delete)
  • Storage doesn''t grow forever

Decision matrix per entity type:

Entity Strategy Retention
User account Soft → hard at 30-90 days 90 days post-cancel
User PII (on GDPR request) Hard immediately None (audit only)
Workspace Soft → hard at 90 days 90 days
Project / document Soft → hard at 30 days 30 days
Task / comment Soft → hard at 30 days 30 days
Session / token Hard immediately None
Audit log Never delete (immutable) 7 years (compliance)
Payment / invoice Never hard delete 7 years (compliance)
Email log Hard at 90 days 90 days

For my system:

  • Per-entity decision
  • Retention period per
  • The exceptions

Output:

  1. The entity-strategy matrix
  2. The retention schedule
  3. The "we never delete this" list

The biggest decision mistake: **forgetting to define retention.** Soft-deleted records accumulate forever; tables grow; queries slow. The fix: every soft-deleted entity has an eventual-hard-delete cron. Set the retention; build the cron; verify it''s running.

## Schema Patterns for Soft Delete

The schema decision affects every query. Get it right.

Help me design the soft-delete schema.

The simplest pattern:

CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  deleted_at TIMESTAMPTZ,         -- NULL = active; non-NULL = deleted
  deleted_by UUID                 -- Who deleted
);

-- Index that excludes deleted rows (partial index)
CREATE INDEX idx_projects_tenant_active
ON projects (tenant_id, created_at DESC)
WHERE deleted_at IS NULL;

Why deleted_at (timestamp) over is_deleted (boolean):

  • Tracks WHEN deletion happened (useful for retention crons)
  • Single column; nullable
  • Enables time-bounded queries (deleted_at > now() - interval '30 days')

Why deleted_by:

  • Audit / forensics
  • Customer support: "Bob deleted this on 2026-04-30"

The partial index pattern:

WHERE deleted_at IS NULL partial indexes are critical:

  • Active rows index stays small (deleted rows don''t bloat)
  • Queries on active rows are fast
  • Storage optimal

Required for every soft-delete table:

-- Active-row queries
CREATE INDEX idx_<table>_<filters>_active
ON <table> (<filters>)
WHERE deleted_at IS NULL;

-- Restoring queries (sometimes)
CREATE INDEX idx_<table>_<filters>_deleted
ON <table> (deleted_at, <filters>)
WHERE deleted_at IS NOT NULL;

The "view" alternative:

Some teams create views that filter soft-deleted:

CREATE VIEW active_projects AS
SELECT * FROM projects WHERE deleted_at IS NULL;

Then app code uses active_projects instead of projects.

Pros: forced filter; harder to forget Cons: more complex; some ORMs don''t handle views well; mutations still hit base table

The ORM trap:

Most ORMs (Prisma, Drizzle, etc.) don''t auto-filter soft-deleted by default. You have to either:

  • Always include WHERE deleted_at IS NULL in queries
  • Use middleware that injects the filter
  • Use a base query / repository that includes the filter

Without discipline: deleted rows show up in queries randomly. Bug.

Drizzle pattern:

// Base query
const activeProjects = (tenantId: string) =>
  db.select().from(projects).where(
    and(
      eq(projects.tenantId, tenantId),
      isNull(projects.deletedAt)
    )
  );

Prisma middleware:

prisma.$use(async (params, next) => {
  if (params.action === 'findMany' || params.action === 'findUnique') {
    if (params.args.where && !params.args.where.deletedAt) {
      params.args.where.deletedAt = null;
    }
  }
  return next(params);
});

For my system:

  • Schema convention
  • Index strategy
  • ORM filtering pattern

Output:

  1. The schema pattern
  2. The migration plan
  3. The ORM filter middleware

The biggest schema mistake: **forgetting partial indexes.** A "deleted_at IS NULL" filter on a 10M-row table without a partial index requires scanning everything; the database keeps scanning soft-deleted rows. The fix: every soft-delete table gets a partial index for the most common active-row query patterns.

## Query Discipline — The Failure Mode

Soft-delete is only correct if EVERY query filters. Discipline matters.

Help me ensure consistent filtering.

The failure modes:

1. Forgot the filter

// Wrong: returns deleted rows
const projects = await db.projects.findMany({ where: { tenantId } });

// Right
const projects = await db.projects.findMany({
  where: { tenantId, deletedAt: null }
});

2. JOIN includes deleted

-- Wrong: project deleted; comment shows up
SELECT c.* FROM comments c
JOIN projects p ON c.project_id = p.id
WHERE c.tenant_id = $1;

-- Right
SELECT c.* FROM comments c
JOIN projects p ON c.project_id = p.id AND p.deleted_at IS NULL
WHERE c.tenant_id = $1 AND c.deleted_at IS NULL;

3. Aggregate counts wrong

-- Wrong: counts deleted
SELECT COUNT(*) FROM projects WHERE tenant_id = $1;

-- Right
SELECT COUNT(*) FROM projects WHERE tenant_id = $1 AND deleted_at IS NULL;

4. Foreign key references deleted parent

User soft-deleted; their comments still point to them; comment screen says "deleted user".

Solutions:

A. Repository / data-access layer

Centralize all queries through a layer that always filters:

class ProjectRepository {
  async findActive(tenantId: string) {
    return db.projects.findMany({
      where: { tenantId, deletedAt: null }
    });
  }

  async findIncludingDeleted(tenantId: string) {
    return db.projects.findMany({
      where: { tenantId }
    });
  }
}

App code uses repository methods; raw queries forbidden in code review.

B. ORM middleware (Prisma example above)

Inject filter at query construction; harder to forget.

C. Database views

CREATE VIEW projects_active AS
SELECT * FROM projects WHERE deleted_at IS NULL;

App reads from view; mutations go to base table.

D. Code-review checklist

If middleware / repository isn''t feasible:

  • Code review enforces "WHERE deleted_at IS NULL" on every read query
  • Slow but works for small teams

The "test for it" rule:

Add tests:

test('soft-deleted projects are not returned', async () => {
  const project = await createProject({ tenantId });
  await softDelete(project.id);

  const projects = await getProjects(tenantId);
  expect(projects).not.toContain(project);
});

Cover at least 1 test per soft-delete table.

The "deleted-row showed up in production" debugging:

When you find a deleted row in a customer-facing query:

  1. Find the query path
  2. Add the filter
  3. Add a test
  4. Audit other queries for the same mistake (often a pattern)

For my system:

  • Filter-discipline approach (repository / middleware / review)
  • Test coverage
  • Audit history

Output:

  1. The filtering approach
  2. The test strategy
  3. The audit checklist

The biggest query-discipline mistake: **"we''ll just remember to filter."** Six developers, each forgetting once a quarter, equals 24 production bugs per year. The fix: middleware or repository pattern. Make it impossible to query without the filter.

## Cascade Behavior — What Happens to Children

When you delete a parent, what happens to children? This needs explicit design.

Help me design cascade behavior.

The four options:

1. CASCADE (delete children)

ALTER TABLE comments
ADD CONSTRAINT fk_comments_project
FOREIGN KEY (project_id) REFERENCES projects(id)
ON DELETE CASCADE;

When parent hard-deleted: children hard-deleted too.

For soft-delete:

async function softDeleteProject(projectId) {
  await db.transaction(async (tx) => {
    await tx.update(projects).set({ deletedAt: now() }).where(eq(projects.id, projectId));
    await tx.update(comments).set({ deletedAt: now() }).where(eq(comments.projectId, projectId));
  });
}

2. RESTRICT (block deletion if children exist)

ALTER TABLE comments
ADD CONSTRAINT fk_comments_project
FOREIGN KEY (project_id) REFERENCES projects(id)
ON DELETE RESTRICT;

If children exist: deletion fails. Customer must delete children first.

3. SET NULL (orphan the children)

ALTER TABLE comments
ADD CONSTRAINT fk_comments_project
FOREIGN KEY (project_id) REFERENCES projects(id)
ON DELETE SET NULL;

Children remain; foreign-key cleared.

Use when:

  • Children make sense without the parent
  • Examples: comments without project; activities without user

4. NO ACTION (deferred check; usually same as RESTRICT)

Practical default for most foreign keys.

The decision per relationship:

Parent → Child Behavior
User → Sessions CASCADE (delete sessions on user delete)
User → Projects SET NULL or "anonymize" (don''t orphan team work)
Project → Tasks CASCADE (tasks dead without project)
Workspace → Members CASCADE (members lose access)
Tenant → All CASCADE (delete entire workspace)
User → Audit logs NEVER delete audit (use SET NULL or keep)
User → Comments "Deleted user" placeholder (preserve thread)

The "team data" rule:

When a user leaves a team, their data shouldn''t vanish:

  • Comments: keep; show "[Deleted User]"
  • Documents: transfer ownership to admin
  • Tasks: keep with attribution preserved

Hard-deleting the user wipes team context. Soft-delete + ownership-transfer is right.

The "anonymize" pattern (for GDPR):

When user invokes right-to-erasure:

  • Replace their PII with anonymous tokens
  • "User_Anon_4f3b2a" instead of "John Doe"
  • Email: deleted-user-{id}@anonymized.local
  • Keep the row (foreign keys intact); remove the personal data

This is GDPR-compliant; preserves business records.

For my system:

  • Foreign-key audit
  • Cascade behavior per relationship
  • Anonymization pattern

Output:

  1. The cascade decision per FK
  2. The implementation
  3. The anonymization plan

The biggest cascade mistake: **CASCADE everywhere.** A user gets deleted; their comments cascade; the team chat history loses 30% of its messages. The fix: cascade only what makes sense (sessions, tokens, owned-only data); preserve team-shared data; anonymize PII when needed.

## GDPR / True Delete: When Soft Isn''t Enough

GDPR right-to-erasure means actual deletion. Soft-delete isn''t enough.

Help me handle GDPR-compliant true delete.

The GDPR Art. 17 rule:

When a user invokes right-to-erasure, you must:

  • Delete personal data without undue delay
  • Communicate to processors (vendors)
  • Document the request

Soft-deleted = NOT compliant. Personal data still in DB.

The implementation:

Per account-deletion-data-export-chat:

Step 1: Receive request

  • Customer self-serve cancel + erasure request
  • Or support-team-initiated request
  • Document timestamp + reason

Step 2: Confirmation period (30 days typical)

  • Soft-delete the account
  • "Are you sure? Reactivate within 30 days"
  • Auto-cancel if no reactivation

Step 3: True deletion (after 30 days)

Hard-delete or anonymize all personal data:

async function trueDeleteUser(userId) {
  await db.transaction(async (tx) => {
    // Hard-delete personally-identifying data
    await tx.delete(users).where(eq(users.id, userId));
    await tx.delete(emailAddresses).where(eq(emailAddresses.userId, userId));

    // Anonymize references
    await tx.update(comments)
      .set({ authorName: '[Deleted User]', authorId: null })
      .where(eq(comments.authorId, userId));

    // Cascade hard-deletes
    await tx.delete(sessions).where(eq(sessions.userId, userId));

    // Audit log: KEEP, but with anonymized identifier
    await tx.update(auditLogs)
      .set({ actorEmail: 'deleted@anonymized.local' })
      .where(eq(auditLogs.actorId, userId));
  });
}

Step 4: Process external systems

For each vendor with user data:

  • Stripe / Customer.io / Mixpanel / etc.
  • API call to delete the user there
  • Document completion

Step 5: Confirm to user

  • Email confirmation
  • Document the deletion (audit log)

The "what stays" list:

Some data MUST be kept by law (or for legitimate interest):

  • Financial records (7 years tax)
  • Audit logs (compliance)
  • Subscription history (for refund disputes)

These can be kept BUT must be anonymized (no PII).

The backup problem:

GDPR true-delete from primary DB doesn''t affect backups. Industry standard:

  • Backups age out (90 days or whatever)
  • Document this in privacy policy
  • Don''t restore deleted users from backup

If you do need to restore from backup: re-delete the GDPR''d users immediately.

The "external services" gotcha:

You may have:

  • Customer.io (email automation)
  • Mixpanel (analytics events)
  • Sentry (error logs with user emails)
  • Stripe (customer records)
  • Each needs separate deletion

Maintain a "vendor deletion checklist" per user-deletion request.

The compliance evidence:

Keep records of:

  • Request received (timestamp)
  • Confirmation period
  • True deletion completed
  • External-service deletion attempts

You may need to provide this to regulators.

For my system:

  • The GDPR deletion flow
  • The vendor checklist
  • The audit-trail strategy

Output:

  1. The deletion implementation
  2. The vendor checklist
  3. The compliance documentation

The biggest GDPR mistake: **assuming soft-delete is enough.** "We deleted the user" but their email is still in the `users` table; their session-IP is in logs; their data is in three SaaS vendors. Regulator finds out; fine arrives. The fix: explicit hard-delete + anonymize-references + vendor-deletion + 30-day backup-aging policy.

## The Retention Cron — Cleanup of Soft-Deleted Rows

Soft-deleted rows accumulate forever without cleanup. Build the cron.

Help me design the retention cron.

The pattern:

// Run daily
export async function purgeOldSoftDeletes() {
  // Per [cron-scheduled-tasks-chat](cron-scheduled-tasks-chat.md)

  const cutoff = subDays(new Date(), 30);  // 30-day retention

  // Hard-delete soft-deleted rows older than cutoff
  await db.delete(projects).where(
    and(
      isNotNull(projects.deletedAt),
      lt(projects.deletedAt, cutoff)
    )
  );

  // Same for comments, tasks, etc.
}

Per-entity retention:

const RETENTION_POLICIES = {
  projects: 30,        // 30 days
  comments: 30,
  tasks: 30,
  workspaces: 90,      // longer for high-stakes
  users: 90,           // user soft-delete → hard at 90 days
  sessions: 0,         // hard-delete immediately
  email_logs: 90,
};

for (const [table, days] of Object.entries(RETENTION_POLICIES)) {
  await purgeTableSoftDeletes(table, days);
}

Cascading retention:

When parent retention triggers, cascade:

  • User retention 90 days = workspace + projects + tasks all hard-deleted
  • Or: each cascades on its own retention from when parent soft-deleted

The "trash" UI complement:

Customer-facing "Trash" / "Recently Deleted" view:

  • Shows soft-deleted items still within retention
  • "Permanently delete now" button (hard-delete immediately)
  • Auto-empty after retention period

The "verify retention is running" alert:

Set monitor:

  • Count of soft-deleted rows older than retention
  • If > 0 for any entity: alert (cron not running)

Storage observability:

Track:

  • Total soft-deleted-row count per table
  • Storage size of soft-deleted rows
  • Trend over time

If growing unbounded: cron broken.

The "manually emptied trash" support:

Users sometimes need data restored from soft-delete:

  • Within retention: customer self-serve restore
  • Past retention: nothing you can do; backup if available
  • Communicate clearly: "30-day recovery window"

For my system:

  • Retention policies per table
  • Cron implementation
  • Trash UI
  • Monitoring

Output:

  1. The retention schedule
  2. The cron design
  3. The customer-facing trash

The biggest retention mistake: **never running the cron.** Soft-deleted rows accumulate over years; tables grow; queries slow; "deleted" data is still there forever. The fix: cron from day one, even with no current scale concern. Otherwise the legacy will hurt at scale.

## Testing the Edge Cases

Soft-delete bugs are subtle. Test them.

Help me test soft-delete edge cases.

The test cases:

1. Active queries don''t return deleted

test('findActive excludes deleted', async () => {
  const project = await createProject();
  await softDelete(project.id);
  const result = await projectRepo.findActive();
  expect(result).not.toContainEqual(project);
});

2. Restore works

test('restore brings back the row', async () => {
  const project = await createProject();
  await softDelete(project.id);
  await restore(project.id);
  const result = await projectRepo.findById(project.id);
  expect(result).toEqual(project);
});

3. Cascade soft-delete

test('soft-deleting parent soft-deletes children', async () => {
  const project = await createProject();
  const task = await createTask(project.id);
  await softDeleteProject(project.id);
  const taskAfter = await taskRepo.findById(task.id);
  expect(taskAfter.deletedAt).not.toBeNull();
});

4. JOIN excludes deleted

test('JOIN with deleted parent excludes child', async () => {
  // Active project; soft-delete it
  // Verify children not returned in JOIN queries
});

5. Counts exclude deleted

test('counts exclude deleted', async () => {
  await createProject();
  const p2 = await createProject();
  await softDelete(p2.id);
  const count = await projectRepo.count();
  expect(count).toBe(1);
});

6. Tenant isolation respected even when deleted

test('soft-deleted from tenant A doesn''t appear in tenant B queries', async () => {
  // Critical: deleted-state doesn''t bypass tenant filter
});

7. Retention cron purges

test('retention cron purges old soft-deletes', async () => {
  const project = await createProject();
  await softDelete(project.id, { at: subDays(now(), 31) });
  await runRetentionCron();
  const after = await projectRepo.findByIdIncludingDeleted(project.id);
  expect(after).toBeNull();
});

8. Hard-delete (GDPR) actually erases

test('GDPR hard-delete removes user PII', async () => {
  const user = await createUser({ email: 'test@example.com' });
  await trueDeleteUser(user.id);
  const result = await db.query(`SELECT * FROM users WHERE email = 'test@example.com'`);
  expect(result.length).toBe(0);
});

9. Sessions hard-deleted on user soft-delete

// Even though user soft-deletes, sessions should hard-delete

10. Audit log preserved through deletion

// User deleted; audit log entries still queryable (anonymized)

For my codebase:

  • Test coverage gaps
  • Critical paths to test
  • Integration tests vs unit tests

Output:

  1. The test plan
  2. The CI integration
  3. The "untested" risks

The biggest testing mistake: **testing only happy-path delete.** "Delete works" tested; "delete + active query" tested; but cascade / JOIN / count / tenant-isolation / retention all untested. The fix: write the 10 tests above; integration test where queries pass through real DB.

## Avoid Common Pitfalls

Recognizable failure patterns.

The soft-delete mistake checklist.

Mistake 1: Soft-delete everything

  • Storage grows forever; "deleted" data still exposed
  • Fix: per-entity decision

Mistake 2: Hard-delete everything

  • No undo; angry customers; lost work
  • Fix: soft-delete user-facing data

Mistake 3: Forgot to filter

  • Deleted rows show up in queries
  • Fix: middleware / repository pattern

Mistake 4: No partial indexes

  • Slow active-row queries
  • Fix: partial index on deleted_at IS NULL

Mistake 5: No retention cron

  • Tables grow forever
  • Fix: per-table retention; cron purges

Mistake 6: GDPR with soft-delete only

  • Personal data still in DB
  • Fix: hard-delete on right-to-erasure

Mistake 7: Cascade soft-delete missed

  • Parent deleted; orphaned children visible
  • Fix: explicit cascade soft-delete logic

Mistake 8: External services not deleted

  • Customer.io / Mixpanel still has user
  • Fix: vendor deletion checklist

Mistake 9: Backups bypass GDPR

  • Backup from before deletion; user "comes back"
  • Fix: backup-aging policy; re-delete on restore

Mistake 10: No tests for edge cases

  • Cascade / JOIN / count bugs lurk
  • Fix: explicit tests

The quality checklist:

  • Per-entity strategy documented
  • Soft-delete schema with deleted_at + deleted_by
  • Partial indexes on active rows
  • Filter discipline (middleware / repository)
  • Cascade behavior explicit
  • Retention cron per soft-delete table
  • GDPR true-delete flow
  • Vendor deletion checklist
  • Test coverage for edge cases
  • Customer-facing trash UI

For my system:

  • Audit
  • Top 3 fixes

Output:

  1. Audit results
  2. Top 3 fixes
  3. The "v2 deletion strategy" plan

The single most-common mistake: **delete strategy is implicit; nobody documented it.** Some tables soft-delete; some hard-delete; some cascade; some don''t — based on whoever wrote that code that day. Six months in, behavior is inconsistent; bugs are subtle; GDPR violations are accidental. The fix: document the strategy; per-entity decision; codify in repository / middleware; test it.

---

## What "Done" Looks Like

A working delete strategy in 2026 has:

- Per-entity strategy documented (soft / hard / soft-then-hard / never-delete)
- Soft-delete schema with `deleted_at`, `deleted_by`, partial indexes
- Filter discipline (middleware / repository / view)
- Cascade behavior explicit per foreign key
- Retention cron purging soft-deletes per policy
- GDPR-compliant hard-delete + anonymization flow
- Vendor deletion checklist for external services
- Customer-facing trash UI for self-restore
- Test coverage for cascades / joins / counts / tenant isolation
- Backup-aging policy aligned with GDPR

The hidden cost of weak deletion: **data leaks through "deleted" boundaries.** A customer asks why their old project still appears in admin search; a regulator asks where the user''s email went; a developer wonders why count(*) returns more than the UI shows. Each "delete" decision compounds; documentation prevents the chaos. Spend an afternoon on the per-entity matrix; save quarters of cleanup.

## See Also

- [Account Deletion & Data Export](account-deletion-data-export-chat.md) — GDPR-compliant deletion flow
- [Audit Logs](audit-logs-chat.md) — preserved through deletion
- [Multi-Tenancy](multi-tenancy-chat.md) — tenant-aware deletion
- [Database Migrations](database-migrations-chat.md) — adding deleted_at columns
- [Database Indexing Strategy](database-indexing-strategy-chat.md) — partial indexes
- [Caching Strategies](caching-strategies-chat.md) — invalidate on soft-delete
- [Cron Jobs & Scheduled Tasks](cron-scheduled-tasks-chat.md) — retention cron
- [Backups & Disaster Recovery](backups-disaster-recovery-chat.md) — backup-aging policy
- [Roles & Permissions](roles-permissions-chat.md) — who can delete
- [Bulk Operations](bulk-operations-chat.md) — bulk delete patterns
- [VibeReference: Database Providers](https://www.vibereference.com/backend-and-data/database-providers) — DB choice
- [LaunchWeek: Trust Center & Security Page](https://www.launchweek.com/4-convert/trust-center-security-page) — GDPR compliance evidence

[⬅️ Day 6: Grow Overview](README.md)