Developer Implementation Guide

Table of Content

Automate retention jobs and TTLs

Keep only what you need, no longer than necessary. Turn retention rules into code with time-bound deletes, legal holds, and tamper-evident audits across databases, object storage, logs, and analytics.

Automate Retention Jobs and TTLs

Keep only what you need, no longer than necessary. Turn retention rules into code with time-bound deletes, legal holds, and tamper-evident audits across databases, object storage, logs, and analytics.

Strategy and governance

  • Define a retention schedule per dataset: purpose, system, selector, duration, action (delete, anonymize, aggregate).

  • Prefer event time over ingestion time for retention cutoffs.

  • Enforce exceptions: legal hold, disputes, fraud investigations, accounting requirements.

-- Retention registry
CREATE TABLE retention_policies (
  id serial PRIMARY KEY,
  dataset text UNIQUE,          -- e.g., "app.sessions", "s3://prod-uploads"
  system text,                  -- postgres, mongo, s3, bigquery, es
  selector text,                -- SQL or path pattern
  duration interval,            -- e.g., '90 days'
  action text CHECK (action IN ('delete','anonymize','aggregate')),
  legal_basis text,             -- purpose or citation
  enabled boolean DEFAULT true
);

-- Dataset-level holds
CREATE TABLE legal_holds (
  id uuid PRIMARY KEY,
  dataset text,
  subject_id text,              -- optional per-subject hold
  reason text,
  created_at timestamptz DEFAULT now(),
  released_at timestamptz
);

Relational stores (Postgres example)

Prefer partitioning by time and drop whole partitions. Fall back to targeted deletes with small batches.

-- Partitioned table
CREATE TABLE events (
  id bigserial,
  occurred_at timestamptz NOT NULL,
  user_id uuid,
  data jsonb
) PARTITION BY RANGE (occurred_at);

-- Monthly partitions
CREATE TABLE events_2025_09 PARTITION OF events
  FOR VALUES FROM ('2025-09-01') TO ('2025-10-01');

-- Scheduled function: drop partitions older than 180 days, unless on hold
CREATE OR REPLACE PROCEDURE enforce_events_retention()
LANGUAGE plpgsql AS $$
DECLARE part record;
BEGIN
  FOR part IN
    SELECT inhrelid::regclass AS p
    FROM pg_inherits
    WHERE inhparent = 'events'::regclass
  LOOP
    -- Derive partition month start from name, or inspect min(occurred_at)
    IF to_date(regexp_replace(part.p::text, '.*_(\d{4})_(\d{2})', '\1-\2-01'), 'YYYY-MM-DD')
       < date_trunc('month', now() - interval '180 days')
    THEN
      -- Skip if any legal_hold matches dataset or subject scope
      IF NOT EXISTS (SELECT 1 FROM legal_holds WHERE dataset = 'events' AND released_at IS NULL) THEN
        EXECUTE format('DROP TABLE IF EXISTS %s CASCADE', part.p);
        INSERT INTO retention_audit(dataset, action, details)
        VALUES ('events','drop_partition', jsonb_build_object('partition', part.p::text));
      END IF;
    END IF;
  END LOOP;
END$$;

-- Simple non-partitioned delete in batches
DELETE FROM sessions
WHERE created_at < now() - interval '90 days'
AND NOT EXISTS (
  SELECT 1 FROM legal_holds h
  WHERE h.dataset = 'sessions' AND h.released_at IS NULL
)
LIMIT 10000;

Document stores

MongoDB TTL index

// Expire 30 days after "createdAt"
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 3600 });

// Disable per-subject via a hold flag
db.sessions.updateMany({ userId: u, hold: true }, { $unset: { createdAt: "" } }); // or move to a hold collection

Redis

# Set TTLs at write time and refresh only when policy allows
SET session:123 value EX 1800

Object storage lifecycle

Amazon S3

{
  "Rules": [
    { "ID": "uploads-365d-delete",
      "Filter": { "Prefix": "uploads/" },
      "Status": "Enabled",
      "Expiration": { "Days": 365 },
      "NoncurrentVersionExpiration": { "NoncurrentDays": 30 },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } }
  ]
}

Google Cloud Storage

{ "rule": [
  { "action": { "type": "Delete" },
    "condition": { "age": 180, "matchesPrefix": ["logs/"] } }
]}

Elasticsearch/OpenSearch ILM

{
  "policy": {
    "phases": {
      "hot":   { "actions": { "rollover": { "max_age": "7d", "max_size": "50gb" } } },
      "warm":  { "min_age": "7d", "actions": { "shrink": { "number_of_shards": 1 } } },
      "delete":{ "min_age": "90d", "actions": { "delete": {} } }
    }
  }
}

Analytics warehouses

BigQuery

-- Partitioned table with expiration
CREATE TABLE events_partitioned
PARTITION BY DATE(occurred_at)
OPTIONS (partition_expiration_days = 365) AS
SELECT * FROM staging_events;

-- Per-partition override
ALTER TABLE events_partitioned
SET OPTIONS (partition_expiration_days = 180);

Snowflake

  • Use tasks to purge old rows based on event time.

  • Set Time Travel and Fail-safe appropriately to avoid retaining data beyond policy.

CREATE TASK purge_events_90d
  WAREHOUSE = ETL_XS
  SCHEDULE = 'USING CRON 15 2 * * * America/New_York'
AS
DELETE FROM EVENTS WHERE OCCURRED_AT < DATEADD(day, -90, CURRENT_TIMESTAMP())
AND NOT EXISTS (SELECT 1 FROM LEGAL_HOLDS WHERE DATASET='EVENTS' AND RELEASED_AT IS NULL);

Orchestration and safety

  • Run deletes off-hours with small batches and backoff.

  • Use idempotent jobs keyed by dataset and window.

  • Add a dry-run mode that reports counts and sampled IDs before execution.

  • Maintain a quarantine path for mistaken deletes and ensure restores re-apply retention rules.

-- Minimal audit
CREATE TABLE retention_audit (
  at timestamptz DEFAULT now(),
  dataset text,
  action text,             -- dry_run, delete, drop_partition, lifecycle_set
  window text,             -- e.g., "< 2025-06-01"
  affected bigint,
  details jsonb
);

Coordination with rights requests

  • Deletion pipelines should mark records with delete_by <timestamp> so retention jobs can clean stragglers.

  • Retention jobs must respect per-subject legal holds and regulator-mandated retention.

ALTER TABLE users ADD COLUMN delete_by timestamptz;
DELETE FROM users
WHERE (created_at < now() - interval '24 months' OR delete_by <= now())
AND NOT EXISTS (SELECT 1 FROM legal_holds h WHERE h.dataset='users' AND h.subject_id = users.id AND h.released_at IS NULL);

Monitoring and SLAs

  • Track rows deleted per run, runtime, errors, and skipped due to holds.

  • Alert on job failures, zero-activity anomalies, or unexpected spikes.

  • Prove enforcement with monthly retention reports from retention_audit.

Quick retention checklist

  • Single source of truth for retention policies and legal holds

  • Time-based partitions or TTL indexes wherever possible

  • Batch deletes with dry-run and audit logs

  • Storage lifecycle rules for buckets and indices

  • Warehouse tasks tied to event time, not load time

  • Per-subject holds and coordination with deletion requests

  • Metrics, alerts, and monthly evidence reports

Conclusion

Retention is a control, not a note in a policy. By codifying durations, automating TTLs and partition drops, honoring legal holds, and auditing every run, you reduce risk, control costs, and align with GDPR and CPRA storage limitation requirements.