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 — store errors are reported (per adapter, see below)
},
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).
How far a fail-loud error travels depends on the adapter. In every adapter the ORM write has already been applied when the audit store fails, so fail-loud reports the failure; to make it block the operation, run the write in a transaction and return the error from the closure.
- GORM — attached to the
*gorm.DBviaAddError; checkdb.Erroror return it from adb.Transactionclosure. - Ent — returned from the mutation as
go-audit: record <action>: <store error>alongside the result. - Bun —
AfterQuerycannot change the error Bun returns, so the query still succeeds and the error goes to theauditbun.WithErrorHandlercallback (default: logged viaConfig.Logger). Use the handler to raise a metric or mark the transaction for rollback. See Bun → Error Handling.
Set Config.Logger so failures are visible to observability: it
receives everything swallowed under ErrorFailSilent and, for Bun,
the fail-loud errors reported by the default handler.
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.