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 deletedWire 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_valuescover JSON key lookups. - Connection pool. Audit writes go through the same
*sql.DByou pass toaudit.New. If you want audit writes isolated from your main pool, open a second*sql.DBand pass that one. - Async writing. For very high throughput, buffer audit rows in
memory and flush in batches via a wrapper
Storeimplementation. Trade-off: a small risk of losing audit rows on crash.
Monitoring
- Row count growth per day (sudden spike = bug or abuse).
- Average
INSERTlatency 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
INSERTintoaudit_logs/audit_api_logs. Humans and support tools should haveSELECTonly. - 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.