Skip to content

Retention Policies

Configure how long audit entries are kept, schedule automated purges, archive entries before deletion, and implement legal holds — all covered on this page.

Set a retention window when creating your audit instance. Entries older than the configured number of days become eligible for purging:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders", "payments"],
retention: { days: 365 },
});
OptionTypeDefaultDescription
daysnumberRequired. Purge entries older than this many days. Must be a positive integer.
tablesstring[]all tablesWhen set, only purge entries for these specific tables.

Different tables may have different compliance requirements. Use tables to scope the retention policy to specific tables:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders", "sessions", "api_requests"],
retention: {
days: 90,
tables: ["sessions", "api_requests"],
},
});

Entries for tables not in the list are kept indefinitely. To purge all audited tables, omit tables.

RegulationTypical requirementSuggested days
SOC 21 year365
GDPRAs short as possible (data minimization)90365
HIPAA6 years2190
PCI DSS1 year (3 months immediately accessible)365
Internal opsVaries3090

The retention config declares the rules. To actually delete old entries, run the purge command via the CLI:

Terminal window
# Preview what would be deleted (safe — no changes made)
npx @usebetterdev/audit-cli purge --dry-run
# Delete entries older than the configured retention period
npx @usebetterdev/audit-cli purge --yes
FlagDefaultDescription
--database-url <url>$DATABASE_URLConnection string to your database.
--since <value>from configOverride the cutoff for this run. Accepts ISO dates (2025-01-01) or duration shorthands (90d, 4w, 3m, 1y).
--batch-size <n>1000Rows per DELETE batch.
--dry-runfalsePrint the number of eligible rows without deleting anything.
--yesfalseSkip the confirmation prompt. Required for non-interactive use.

Schedule the purge command to run automatically. The --yes flag is required for non-interactive execution:

.github/workflows/audit-purge.yml
name: Audit log purge
on:
schedule:
- cron: "0 3 * * *" # Daily at 3 AM UTC
jobs:
purge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: npx @usebetterdev/audit-cli purge --yes
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}

The CLI uses batched deletes (1,000 rows per batch by default) to avoid holding long row-level locks on large tables. Each batch runs a DELETE … WHERE id IN (SELECT id … LIMIT n) query, which works across PostgreSQL, MySQL, and SQLite. Progress is reported to stderr every 10 batches.

Priority for resolving the cutoff date:

  1. --since flag (if provided)
  2. retention.days from your better.config file

If neither is set, the command exits with: No retention policy configured. Pass --since <date|duration> or set audit.retention.days in your better.config file.

For regulations that require long-term access to historical entries, archive before purging.

Use the CLI export command to save entries before they are deleted:

Terminal window
# Export entries older than 1 year to a JSON file, then purge them
npx @usebetterdev/audit-cli export \
--since 365d \
--format json \
-o archive-older-than-365d.json \
--database-url $DATABASE_URL
npx @usebetterdev/audit-cli purge --since 365d --yes --database-url $DATABASE_URL

Combine export and purge into a single script for your CI pipeline:

scripts/archive-and-purge.sh
#!/usr/bin/env bash
set -euo pipefail
ARCHIVE_DIR="./audit-archives"
DATE=$(date -u +%Y-%m-%d)
RETENTION_DAYS=365
# GNU date || macOS date
CUTOFF=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT00:00:00Z 2>/dev/null \
|| date -u -v-${RETENTION_DAYS}d +%Y-%m-%dT00:00:00Z)
mkdir -p "$ARCHIVE_DIR"
echo "Exporting entries older than ${RETENTION_DAYS} days..."
npx @usebetterdev/audit-cli export \
--to "$CUTOFF" \
--format json \
-o "${ARCHIVE_DIR}/audit-archive-${DATE}.json" \
--database-url "$DATABASE_URL"
echo "Purging exported entries..."
npx @usebetterdev/audit-cli purge \
--since "${RETENTION_DAYS}d" \
--yes \
--database-url "$DATABASE_URL"
echo "Done. Archive saved to ${ARCHIVE_DIR}/audit-archive-${DATE}.json"
Cold storage recommendations
Storage tierUse caseExample
Hot (database)Active queries, dashboards, real-time alertsYour primary database
Warm (object storage)Compliance audits, investigation lookbacksS3 Standard, GCS Standard
Cold (glacier)Long-term legal retentionS3 Glacier, GCS Archive

Move archives to progressively cheaper storage tiers as they age. Most compliance audits only need warm-tier access.

A legal hold suspends normal retention rules for entries relevant to an ongoing investigation or litigation. While Better Audit does not enforce legal holds at the database level, you can implement them using compliance tags and scoped retention.

Use enrichment to tag entries that should be preserved:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders", "payments"],
retention: {
days: 90,
tables: ["orders"], // Only auto-purge orders
},
});
// Tag sensitive operations for legal preservation
audit.enrich("users", "DELETE", {
label: "User account deleted",
severity: "critical",
compliance: ["gdpr", "legal-hold"],
});
audit.enrich("payments", "*", {
severity: "high",
compliance: ["pci", "legal-hold"],
});

Scope your retention policy to only purge tables that are not under legal hold. Tables not listed in retention.tables are kept indefinitely:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders", "payments", "sessions", "api_requests"],
retention: {
days: 90,
tables: ["sessions", "api_requests"], // Only purge these tables
},
// users, orders, payments → kept indefinitely (legal hold)
});

Use compliance tag filtering to extract entries relevant to a legal matter:

Terminal window
# Export all entries tagged with legal-hold
npx @usebetterdev/audit-cli export \
--compliance legal-hold \
--format json \
-o legal-hold-export.json \
--database-url $DATABASE_URL
# Export legal-hold entries for a specific actor
npx @usebetterdev/audit-cli export \
--compliance legal-hold \
--actor user-42 \
--format json \
-o legal-hold-user-42.json \
--database-url $DATABASE_URL

When the hold is lifted, remove the table exclusion from your retention config so those entries become eligible for normal purging again:

  1. Confirm with your legal team that the hold can be released.

  2. Update your retention config to include the previously held tables:

    src/audit.ts
    const audit = betterAudit({
    database: drizzleAuditAdapter(db),
    auditTables: ["users", "orders", "payments", "sessions", "api_requests"],
    retention: {
    days: 90,
    // tables omitted → all audited tables are now eligible for purging
    },
    });
  3. Run a dry-run to verify what will be purged:

    Terminal window
    npx @usebetterdev/audit-cli purge --dry-run
  4. Export a final archive of the held entries before purging:

    Terminal window
    npx @usebetterdev/audit-cli export \
    --compliance legal-hold \
    --format json \
    -o legal-hold-final-archive.json \
    --database-url $DATABASE_URL
  5. Run the purge:

    Terminal window
    npx @usebetterdev/audit-cli purge --yes