I am a...
Learn more
How it worksPricingFAQ
Account
May 17, 2026 · 11 min read · Cadence Editorial

How to handle data retention policies in SaaS

data retention saas — How to handle data retention policies in SaaS
Photo by [Brett Sayles](https://www.pexels.com/@brett-sayles) on [Pexels](https://www.pexels.com/photo/server-racks-on-data-center-5480781/)

How to handle data retention policies in SaaS

A SaaS data retention policy is a written rule plus an automated job that decides, for every table in your database, how long a row lives before it gets deleted, anonymized, or archived. The policy lives in your repo as code (a YAML file or a TypeScript config), not in a PDF on a shared drive. The job runs nightly, logs every action, and is the single source of truth for GDPR, SOC 2, and customer DPA commitments.

Most SaaS teams write a retention policy once for the SOC 2 audit, paste it into a Notion page, and never run a single deletion job. That's the failure mode. The fix is to treat retention like any other piece of production behavior: spec it in code, schedule it, monitor it, and alert on drift.

Why this matters more in 2026

Two things changed in the last 18 months.

First, regulators got specific. GDPR enforcement actions in 2025 moved past cookie banners and into "you kept user data 4 years longer than your privacy policy said you would." The Irish DPC's €91M fine on Meta in late 2024 was partly a retention issue. Enterprise buyers now ask for retention schedules in their security questionnaires, not just an SOC 2 report.

Second, AI features changed the economics. Every prompt you send to OpenAI, every embedding you store in pgvector, every transcript you keep for "we'll fine-tune later" is a retention liability. Storage is cheap. Breaches involving 4-year-old data you forgot you had are not.

The teams handling this well in 2026 share one pattern: they treat the retention policy as a retention.config.ts file in the repo, reviewed in PRs, executed by a scheduled job, and tested in CI like everything else.

The default approach (and why it breaks)

Most SaaS teams reach for one of three defaults. All three fail at different scales.

Default 1: "We'll delete it manually when someone asks." This works for the first 50 customers. By customer 500 you have a Linear ticket queue of GDPR Article 17 deletion requests, no audit trail of which got actioned, and a Postgres table called users_old that nobody remembers why exists.

Default 2: "Cascading deletes will handle it." Setting ON DELETE CASCADE on the foreign key from users to events means deleting a user wipes their event log. Great for compliance, terrible for analytics and finance. You also lose the ability to keep a tombstone row for fraud investigation.

Default 3: "Our cloud provider handles retention." S3 lifecycle policies and Snowflake time-travel are real features, but they retain by object age, not by relationship age. A user who deleted their account 2 years ago still has events sitting in your warehouse because the events were written yesterday by your ETL backfill.

The right model is per-table policies with explicit lifecycles, executed by code you own, not implicit behavior at the storage layer.

The better approach: 6 steps

Here is the playbook we recommend to founders shipping their first retention policy. It assumes a Postgres-backed SaaS (Supabase, Neon, RDS, or self-hosted) and a Node/TypeScript or Python stack, but the pattern translates to anything with cron and a database.

1. Inventory every table and classify the data

Spend a morning building a spreadsheet (or better, a retention.config.ts file) with one row per table. For each, capture:

  • Table name
  • Data class (PII, billing, telemetry, system, derived)
  • Regulatory basis (GDPR, CCPA, SOC 2, HIPAA, none)
  • Retention period
  • Action at end of life (hard delete, soft delete, anonymize, archive)
  • Trigger (account deletion, time-based, user request)

Most teams find they have 40 to 80 tables and 4 to 6 distinct retention rules. The pattern is usually: PII tied to active accounts is kept indefinitely, PII tied to deleted accounts gets 30 days, billing records 7 years, telemetry 13 months, logs 90 days.

The output of this step is a config you can review in a pull request. This is the moment your privacy policy and your database actually agree with each other, often for the first time.

2. Pick three lifecycle actions and stick with them

Don't invent ten action types. The teams that ship pick three.

  • Hard delete for things that have no business value once retention expires (raw analytics events, expired session tokens, abandoned signup attempts).
  • Anonymize for things that have aggregate analytics value but contain PII (replace email with deleted-{hash}@example.invalid, null the name, keep the row).
  • Archive for things you might need for legal or finance (move to S3 Glacier or a cold table, with retrieval requiring a manual ticket).

This becomes the action enum in your config. Three values, no exceptions.

3. Write the retention runner as boring code

This is one file. We call it lib/retention/runner.ts. It reads the config, loops each table, runs the action, logs every row touched to a retention_audit table, and exits.

// pseudo-code, ~80 lines in practice
for (const rule of config.rules) {
  const candidates = await db.query(rule.findExpiredSql);
  for (const row of candidates) {
    await applyAction(rule.action, row);
    await auditLog.insert({
      table: rule.table,
      rowId: row.id,
      action: rule.action,
      ruleId: rule.id,
      executedAt: new Date(),
    });
  }
}

The retention_audit table is the single most useful thing you'll build. Auditors love it, your SOC 2 prep accelerates, and when a customer asks "did you delete my data?" you query one table and email them the rows.

A senior engineer can ship the runner, the audit table, and the first three rules in 2 days. We see this regularly: median time to first commit on Cadence is 27 hours, and retention runners are exactly the bounded scope that fits that window.

4. Schedule it as a real job, not a manual cron

Run it nightly at 02:00 UTC with three guardrails.

  • Idempotent. Running it twice in a row should be safe. Use WHERE retained_until < NOW() AND deleted_at IS NULL as the candidate query, not row counts.
  • Dry-run flag. Every run produces a count summary in Slack before execution: "Would delete 1,247 sessions, anonymize 3 users, archive 891 events." Set a threshold (say, 5% of table) that flips to dry-run automatically and pages the on-call.
  • Bounded batches. Process 10,000 rows at a time, not 10 million. Your replication lag will thank you.

Pick whatever your stack uses for scheduled work: GitHub Actions on a cron schedule for small teams, Inngest or Trigger.dev for serverless setups, BullMQ on Redis for self-hosted, Temporal for anything mission-critical. The runner doesn't care; the scheduler does.

5. Add deletion endpoints for user requests

GDPR Article 17 and CCPA both grant users the right to request deletion. Build two endpoints:

  • POST /api/account/delete triggers the user-initiated path. Soft-deletes the account, schedules anonymization in 30 days (your grace window for accidental clicks).
  • An internal POST /admin/dsr endpoint for ops to handle DSAR requests that come in through email or support tickets. Same code path, manual trigger.

Both write to retention_audit with trigger: 'user_request' so you can prove SLA compliance during audits. Most regulations require 30 days; aim for 7.

6. Test the policy in CI

The most common production bug is "we wrote the retention rule for users but forgot the foreign-key cascade on api_keys, so an api_key row references a non-existent user and now reads fail." Catch this in CI.

Write one integration test per rule that asserts: "given a row that should expire, after running the runner, the row is in the expected end state and no foreign keys are broken." This pairs well with the patterns in our guide on running integration tests in CI since you need real Postgres, not mocks.

A reference retention schedule for B2B SaaS

This is the schedule we hand founders as a starting point. Adapt to your specific DPA commitments and industry.

Data typeRetention periodAction at end of lifeRegulatory basis
Active user PIIIndefinite (while active)N/ADPA contract
Deleted user PII30 days grace, then anonymizeAnonymizeGDPR Art. 17
Billing records (invoices, payments)7 years from issueArchive to S3 GlacierIRS, SOX
Subscription events13 monthsHard deleteInternal analytics
Session tokens30 days post-expiryHard deleteSecurity hygiene
Audit logs13 monthsArchiveSOC 2
Error logs (Sentry, Datadog)90 daysHard deleteCost, privacy
AI prompt logs30 daysHard deleteDPA, OpenAI policy
Embeddings (pgvector)Tied to source rowCascade deleteGDPR
Backup snapshots35 days rollingAuto-expireDisaster recovery
Customer support tickets3 yearsArchiveService quality
Marketing event data25 monthsHard deleteGDPR + Google Analytics standard

The two numbers that catch teams off guard: backups (your nightly Postgres snapshot still has the deleted user for 35 days, and that's fine for SOC 2 if disclosed) and AI prompt logs (most teams forget these exist and accidentally keep customer prompts for years).

Common pitfalls

Five patterns we see break in production.

Cascading deletes that wipe analytics. Your users -> events FK with ON DELETE CASCADE means deleting one user nukes their entire event history. If you use Mixpanel or PostHog for analytics, the source-of-truth is already gone before the next sync. Use soft delete on users and anonymize the email instead.

Anonymization that isn't anonymization. Replacing alice@startup.com with user_12345@example.invalid is pseudonymization, not anonymization, if the id column still links to a profile photo, IP address, and 3 years of detailed events. True anonymization means breaking the join key.

Forgetting derived data. You delete the user's row from users and feel good. The recommendation_cache table, the pgvector embeddings, the audit_log of their actions, and the warehouse copy in BigQuery all still have their data. Map every derived table back to the source in step 1.

Retention rules that don't match the privacy policy. Your policy says "we retain logs for 90 days." Your CloudWatch retention is set to "never expire." This is exactly the kind of mismatch that turns a routine audit into a finding. Use the same retention.config.ts to validate both your DB and your log retention via Pulumi or Terraform. The patterns in our Pulumi infrastructure guide cover how to declare log group retention as code so it can't drift.

Not deleting from object storage. S3 keys for user-uploaded files. Avatars in Supabase Storage. Generated PDFs in R2. These aren't in your Postgres database, so your retention runner never touches them. Build a parallel S3 prefix-based job that mirrors the same rules.

When you can skip this entirely

If you have fewer than 50 customers, no enterprise pipeline, and no regulated data (no health, financial, or EU PII), you can defer the full retention runner. Write the policy as a 1-page doc, keep all data, and revisit at 50 paying customers or your first SOC 2 prep, whichever comes first.

What you cannot skip even on day one: an account deletion endpoint that actually deletes within 30 days, and a privacy policy that matches what you do. The runner can wait. The credible promise to delete cannot.

If you're past 50 customers or chasing your first SOC 2, the retention runner is on the critical path. This is exactly the kind of bounded, ship-it-in-a-week project a senior engineer on Cadence at $1,500/week handles well: inventory the tables, write the runner, ship the audit log, hand off the config for your team to maintain. Two weeks, one engineer, fully tested in CI.

What to do next

Pick one of three paths based on where you are:

  1. No policy at all: Block 4 hours this week. Build the inventory spreadsheet from step 1. The exercise alone will surface 3 tables you forgot you had.
  2. Policy exists but no code: Spend a week writing the runner. Start with the easy wins (session tokens, expired tokens, old logs) before touching user PII.
  3. Runner exists, no audit log: Add the retention_audit table this sprint. You'll need it for your next SOC 2 cycle whether you know it yet or not.

If you're staring at a SOC 2 audit in 90 days and don't have the bandwidth, the fastest path is to book a senior engineer who has shipped retention runners before. Audit your current setup with ship-or-skip for an honest grade on where the gaps are, or book a Senior tier engineer for 2 weeks to ship the whole thing end-to-end.

Try Cadence for retention work. Senior engineers at $1,500/week, 48-hour free trial, no contracts. They've shipped this exact playbook for 20+ SaaS teams. Spec the work in 2 minutes, get matched, ship in a week.

The cost of getting this wrong (GDPR fine, lost enterprise deal, breach involving data you forgot you had) is orders of magnitude higher than the cost of doing it right (one senior engineer, 2 weeks, ~$3,000). The math is rarely this clear.

For the broader engineering context, our guides on managing technical debt in a startup and writing a technical specification that engineers actually follow cover how to scope and ship this kind of cross-cutting work without it sprawling into a 3-month rewrite.

FAQ

How long should a SaaS company retain user data?

It depends on the data class. Active user PII is kept while the account is active. Deleted user PII gets a 30-day grace then anonymization (per GDPR Article 17). Billing records: 7 years (IRS). Telemetry: 13 months. Session tokens: 30 days post-expiry. Logs: 90 days. AI prompts: 30 days. The right answer is "different for each table, written in code, executed nightly."

Is a written data retention policy enough for SOC 2?

No. SOC 2 Type II auditors will ask for evidence that the policy is enforced, not just documented. That means a retention_audit table showing rows deleted on schedule, a runner that executes on a cron, and tests that prove the runner works. A PDF in Notion is necessary but not sufficient.

What's the difference between deletion and anonymization?

Hard deletion removes the row entirely. Anonymization keeps the row but strips PII (replace email with a hash, null the name, drop the IP). Anonymization preserves analytics and aggregate metrics; deletion preserves nothing. GDPR accepts either, but only true anonymization (no possible re-identification) qualifies as "no longer personal data."

Do backups need to comply with the retention policy?

Mostly no, if disclosed. Your nightly Postgres snapshot will contain a deleted user for the snapshot's lifetime (usually 30 to 35 days). SOC 2 and GDPR both accept this if your privacy policy discloses the backup window and you don't restore from backup to reactivate deleted accounts. Document the backup retention explicitly.

How do AI features change retention requirements?

A lot. Every prompt sent to OpenAI, every embedding stored in pgvector, every transcript kept for model fine-tuning is a retention liability. The new pattern: 30-day max retention on raw prompts, cascade-delete embeddings when the source row is deleted, sign DPAs with your AI vendors that match your own retention commitments to customers. Most 2025-era SOC 2 audits now include an AI-specific section.

Can I use Postgres ON DELETE CASCADE for retention?

For some tables, yes. For user PII deletion, usually not, because cascading deletes wipe analytics and audit history. Better pattern: soft-delete the parent row, anonymize derived rows, hard-delete only the truly disposable data (sessions, tokens, raw events). The retention runner gives you per-table control that CASCADE does not.

All posts