go-auditGo Audit
ORM Adapters

GORM Adapter

Use Go Audit with GORM via the auto-hook plugin.

The GORM adapter registers callbacks on the GORM connection so every Create, Save, Updates, and Delete is tracked with no changes to your model or query code.

Installation

go get github.com/gopackx/go-audit/adapters/gorm

Setup

import (
    "context"
    "database/sql"

    "github.com/gopackx/go-audit"
    auditgorm "github.com/gopackx/go-audit/adapters/gorm"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
    return err
}
sqlDB, err := gormDB.DB()
if err != nil {
    return err
}

auditor, err := audit.New(sqlDB, audit.Config{
    Dialect: audit.PostgreSQL,
    UserFunc: func(ctx context.Context) (string, string) {
        return "user-123", "user"
    },
    DataAudit: audit.DataAuditConfig{Enabled: true},
})
if err != nil {
    return err
}

if err := gormDB.Use(auditgorm.Plugin(auditor)); err != nil {
    return err
}
if err := auditor.AutoMigrate(context.Background()); err != nil {
    return err
}

Note the two-step DB handle: audit.New takes the underlying *sql.DB (from gormDB.DB()), while Plugin attaches to the *gorm.DB.

How It Works

The plugin registers five callbacks:

  • after_create — records ActionCreate with new_values.
  • before_update — snapshots the affected rows via a SELECT that targets the same rows as the write: its WHERE clause and the model's primary key (unless DataAudit.SkipOldValues is set).
  • after_update — diffs the snapshot against the new values, records ActionUpdate with only the changed fields.
  • before_delete — snapshots the affected rows the same way as before_update; detects soft deletes via presence of a deleted_at field.
  • after_delete — records ActionDelete (hard delete) or ActionSoftDelete (soft delete via gorm.DeletedAt).

Errors from audit writes are attached to the *gorm.DB via AddError, so callers see them via db.Error.

Supported Operations

  • db.Create() / db.CreateInBatches()
  • db.Save()
  • db.Updates() (struct or map[string]any)
  • db.UpdateColumn() / db.UpdateColumns()
  • db.Delete() — hard and soft deletes
  • Bulk operations — all affected rows share one transaction_id

Bulk Updates and Deletes

Bulk updates and deletes produce one audit log per affected row, all sharing the same transaction_id:

Go
db.Model(&Product{}).Where("price < ?", 100).Updates(Product{Status: "archived"})
// -> N audit logs, one per row, same transaction_id

db.Where("price < ?", 100).Delete(&Product{})
// -> N audit logs (action "delete" or "soft_delete"), one per row
Go snippet

A WHERE-driven bulk write carries no primary key on its model, so the affected rows are identified by the pre-write snapshot: the SELECT runs with the same WHERE, and each row it returns becomes one entry with:

  • entity_id — that row's primary key
  • old_values — the snapshot
  • new_values — the snapshot with the assigned columns applied (null for a hard delete, {"deleted_at": ...} for a soft delete)

With DataAudit.SkipOldValues: true no snapshot is taken, so such bulk writes cannot be attributed per row and are not audited. Writes that target rows by primary key (db.Model(&p).Update(...), db.Save(&p), db.Delete(&p), db.Delete(&rows)) are still audited per row without a snapshot.

A write with neither a WHERE clause nor a primary key is rejected by GORM (ErrMissingWhereClause) and no snapshot is taken. With AllowGlobalUpdate the write touches the whole table, so the snapshot does too.

Configuration

Go
audit.Config{
    Dialect: audit.PostgreSQL,
    DataAudit: audit.DataAuditConfig{
        Enabled:         true,
        Table:           "audit_logs", // override table name
        ExcludeFields:   []string{"password", "token"},
        ExcludeEntities: []string{"sessions"},
        SkipOldValues:   false, // true = skip pre-UPDATE SELECT
        OnError:         audit.ErrorFailLoud,
    },
    UserFunc: func(ctx context.Context) (string, string) {
        return "user-123", "user"
    },
}
Go snippet

Soft Delete Detection

If your model includes a gorm.DeletedAt field, GORM performs an UPDATE rather than a DELETE. The adapter detects this and records the action as "soft_delete" with the new deleted_at value in new_values. Hard deletes (e.g. .Unscoped().Delete()) are recorded as "delete" with new_values: null — including an Unscoped() delete of a row that was already soft-deleted.

Caveats

  • Raw SQL executed via db.Exec() bypasses GORM's callbacks and is not audited. Use model-based calls when you want audit trail.
  • db.Session(&gorm.Session{SkipHooks: true}) explicitly skips audit writes.
  • Queries that don't go through GORM (e.g. direct database/sql on the same connection) are not audited.

On this page