Data Retention & Legal Holds
Retention policies collide: GDPR right to erasure, SOC2 audit trails, and legal hold litigation freezes. Postgres implements policy through partitioning, soft delete, and immutable audit tables with clear ownership.
Search across all documentation pages
Retention policies collide: GDPR right to erasure, SOC2 audit trails, and legal hold litigation freezes. Postgres implements policy through partitioning, soft delete, and immutable audit tables with clear ownership.
Quick-reference recipe card - copy-paste ready.
-- Retention metadata on partitioned events
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id uuid NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
-- Legal hold flag (separate table avoids rewriting history)
CREATE TABLE legal_holds (
subject_type text NOT NULL,
subject_id uuid NOT NULL,
hold_until date,
PRIMARY KEY (subject_type, subject_id)
);When to reach for this: GDPR erasure requests, finance audit retention, litigation hold, or log table disk growth.
Customer requests deletion under GDPR. Finance requires 7-year invoice retention.
-- Pseudonymize PII in shared invoice row (retain financial facts)
UPDATE customers
SET email = 'deleted-' || id::text || '@invalid.local',
name = 'REDACTED',
deleted_at = now()
WHERE id = $1;
-- Block delete if legal hold active
SELECT 1 FROM legal_holds
WHERE subject_type = 'customer' AND subject_id = $1
AND (hold_until IS NULL OR hold_until >= current_date);-- Drop old event partitions past retention (no hold rows in partition)
ALTER TABLE events DETACH PARTITION events_2023_q1;
DROP TABLE events_2023_q1;What this demonstrates:
| Data class | Typical retention | Erasure |
|---|---|---|
| Marketing events | 13-24 months | Hard delete |
| Invoices | 7 years tax | Pseudonymize PII only |
| Audit admin actions | 3-7 years | Immutable append-only |
| Application logs in DB | 30-90 days | Partition drop |
-- Soft delete pattern
ALTER TABLE customers ADD COLUMN deleted_at timestamptz;
CREATE INDEX customers_active_idx ON customers (id) WHERE deleted_at IS NULL;
-- RLS hides deleted rows from app role
CREATE POLICY customers_active ON customers
FOR SELECT TO app_role
USING (deleted_at IS NULL);Hard delete cascades only when legal confirms no hold and no statutory retention.
REVOKE UPDATE, DELETE ON audit_log FROM PUBLIC;
GRANT INSERT ON audit_log TO app_audit_role;
-- Optional: pgaudit or trigger to append-only enforcement# Monthly cron: drop partitions older than policy
psql -c "SELECT drop_expired_event_partitions(24);" # monthsdeleted_at IS NULL.ON DELETE RESTRICT on regulated children.| Alternative | Use When | Don't Use When |
|---|---|---|
| Archive to cold storage | Long retention cheap | Need SQL query on archive |
| Separate audit cluster | Heavy compliance | Small team ops burden |
| Event streaming to SIEM | Logs leave Postgres | DB is system of record |
| Anonymization function | Repeatable erasure | Re-identification risk remains |
Often pseudonymization suffices when statutory retention applies; legal decides per request.
Flag in legal_holds; jobs skip delete/partition drop for flagged subjects.
Yes for time-bound data; tenant-bound may need row-level purge or shard per tenant.
Deletes replicate; ensure cascade behavior identical on subscribers.
Retention reduces row count; vacuum reclaims space after delete or drop.
Legal + product own policy; engineering implements automation and audits.
Isolate cardholder data schema; stricter retention and access logging.
Audit log entry with request ID, tables touched, row counts, timestamp.
Export job joins customer key; separate from delete runbook.
See Retention and Detach.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026