go-auditGo Audit
Guides

Production Tips & Best Practices

Keep audit logs fast, small, and tamper-resistant at scale.

Go Audit writes one INSERT per CRUD (plus one SELECT per UPDATE for snapshotting, unless SkipOldValues is set). For most apps that's fine. At scale there are a few things worth doing.

Retention via Purge

Use the built-in Purge to trim old rows on a schedule:

cutoff := time.Now().AddDate(-1, 0, 0) // 1 year
result, err := auditor.Purge(ctx, cutoff)
// result.DataLogs, result.APILogs: int64 rows deleted

Wire this into a daily cron. For very large tables, consider the partitioning strategy below so retention is a partition-drop rather than a row-delete.

Table Partitioning

For large datasets, partition audit_logs by created_at:

-- PostgreSQL declarative partitioning
CREATE TABLE audit_logs_2026_q2 PARTITION OF audit_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');

Dropping an old partition is O(1) compared to a DELETE that scans and rewrites rows.

Error Handling Mode

Choose between strict and relaxed modes per audit table:

DataAudit: audit.DataAuditConfig{
    Enabled: true,
    OnError: audit.ErrorFailLoud, // default — errors bubble up
},
APIAudit: audit.APIAuditConfig{
    Enabled: true,
    OnError: audit.ErrorFailSilent, // log and continue
},

Use ErrorFailLoud for compliance-strict environments where an un-audited write is unacceptable. Use ErrorFailSilent when the main write must not be coupled to audit availability (e.g. degraded mode during a planned audit-DB maintenance window).

When using ErrorFailSilent, set Config.Logger so failures are visible to observability.

Skip Old Values on Hot Paths

For extreme write volume on tables where you don't need old_values:

DataAudit: audit.DataAuditConfig{
    Enabled:       true,
    SkipOldValues: true,
}

This drops the pre-UPDATE SELECT, halving the write path cost. The trade-off: UPDATE/DELETE audit rows lose the "before" picture. Good for event-style tables where the new state is all you need.

Performance

  • Indexes. The default indexes cover entity, user, transaction, action, and created-at queries. On PostgreSQL, GIN indexes on old_values / new_values cover JSON key lookups.
  • Connection pool. Audit writes go through the same *sql.DB you pass to audit.New. If you want audit writes isolated from your main pool, open a second *sql.DB and pass that one.
  • Async writing. For very high throughput, buffer audit rows in memory and flush in batches via a wrapper Store implementation. Trade-off: a small risk of losing audit rows on crash.

Monitoring

  • Row count growth per day (sudden spike = bug or abuse).
  • Average INSERT latency per table (slow = index bloat or lock contention).
  • Redaction failures (malformed bodies, encoding errors) — these surface through Config.Logger.

Security

  • Restrict writes. Only the app's DB user should be able to INSERT into audit_logs / audit_api_logs. Humans and support tools should have SELECT only.
  • Tamper-evidence. For high-stakes audit trails, chain rows with a rolling hash column (prev_hash, row_hash) so any tampering is detectable. This is an application-level addition on top of Go Audit.
  • Separate credentials. Where possible, run audit writes with a DB user whose grants are narrower than your main app user's.

On this page