Skip to content
gohacks.dev
Databases

GORM Hooks and Callbacks and When You Should Avoid Them

Which GORM operations fire BeforeCreate, AfterCreate, BeforeUpdate and AfterFind, which skip them, and why a hook side effect survives a rollback

8 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

GORM lets a model define methods that run around database operations: BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate, BeforeSave, AfterSave, BeforeDelete, AfterDelete and AfterFind. They are attractive because they keep rules next to the data: set a slug before saving, hash a password, write an audit row. They are also hard to reason about, because which write path fires them is not obvious from the call site, and the code inside them runs in a transaction that has not committed. This article logs every hook call for each write path against PostgreSQL 18.6, so you can see exactly which operations run your code and which do not.

Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It extends the GORM tutorial and sits beside transactions, because the transaction is where the sharpest edge is. For shared query logic, scopes are a better home, and the wider database picture is in the database cornerstone.

The lab

The Order model defines four hooks, each appending a line to a log slice. BeforeCreate computes a code from the total and rejects a negative total. After each operation the program prints the lines the hooks wrote.

main.goGo
package main

import (
	"errors"
	"fmt"
	"log"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func must(err error) {
	if err != nil {
		log.SetFlags(0)
		log.Fatal(err)
	}
}

// connect recreates a schema and returns a connection whose search_path points at it.
func connect(schema string, cfg *gorm.Config) *gorm.DB {
	base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
	admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
	must(err)
	must(admin.Exec("drop schema if exists " + schema + " cascade").Error)
	must(admin.Exec("create schema " + schema).Error)
	if cfg == nil {
		cfg = &gorm.Config{Logger: logger.Discard}
	}
	db, err := gorm.Open(postgres.Open(base+" search_path="+schema), cfg)
	must(err)
	return db
}

type Order struct {
	ID    uint
	Code  string
	Total int
	Notes string
	Seen  int `gorm:"-"`
}

var log2 []string

func note(s string) { log2 = append(log2, s) }

func (o *Order) BeforeCreate(tx *gorm.DB) error {
	if o.Total < 0 {
		return errors.New("negative total")
	}
	o.Code = fmt.Sprintf("ORD-%d", o.Total)
	note("BeforeCreate " + o.Code)
	return nil
}

func (o *Order) AfterCreate(tx *gorm.DB) error {
	note("AfterCreate " + o.Code + " (still inside the transaction)")
	return nil
}

func (o *Order) BeforeUpdate(tx *gorm.DB) error {
	note("BeforeUpdate")
	return nil
}

func (o *Order) AfterFind(tx *gorm.DB) error {
	o.Seen++
	return nil
}

func flush(label string) {
	fmt.Printf("%-34s %v\n", label, log2)
	log2 = nil
}

func main() {
	db := connect("l0711", nil)
	must(db.AutoMigrate(&Order{}))

	o := Order{Total: 10}
	db.Create(&o)
	flush("Create")

	db.Create(&[]Order{{Total: 1}, {Total: 2}})
	flush("Create slice (hook per row)")

	err := db.Create(&Order{Total: -1}).Error
	flush("Create with error: " + err.Error())

	db.Model(&o).Update("notes", "a")
	flush("Update")
	db.Model(&o).Updates(map[string]any{"notes": "b"})
	flush("Updates(map)")
	db.Model(&o).UpdateColumn("notes", "c")
	flush("UpdateColumn")
	db.Model(&Order{}).Where("id > 0").Update("notes", "d")
	flush("Model(&Order{}).Where().Update")
	db.Exec("update orders set notes = 'e'")
	flush("Exec")

	var one Order
	db.First(&one, o.ID)
	var many []Order
	db.Find(&many)
	fmt.Println("AfterFind ran per row:", one.Seen, many[0].Seen, len(many))

	// The hook runs inside the transaction, so a rollback cannot take its side effect back.
	db.Transaction(func(tx *gorm.DB) error {
		tx.Create(&Order{Total: 99})
		return errors.New("later step failed")
	})
	flush("Create then rollback")
	var n int64
	db.Model(&Order{}).Where("total = 99").Count(&n)
	fmt.Println("rows with total 99 after rollback:", n)
}
Text
Create                             [BeforeCreate ORD-10 AfterCreate ORD-10 (still inside the transaction)]
Create slice (hook per row)        [BeforeCreate ORD-1 BeforeCreate ORD-2 AfterCreate ORD-1 (still inside the transaction) AfterCreate ORD-2 (still inside the transaction)]
Create with error: negative total  []
Update                             [BeforeUpdate]
Updates(map)                       [BeforeUpdate]
UpdateColumn                       []
Model(&Order{}).Where().Update     [BeforeUpdate]
Exec                               []
AfterFind ran per row: 1 1 3
Create then rollback               [BeforeCreate ORD-99 AfterCreate ORD-99 (still inside the transaction)]
rows with total 99 after rollback: 0

What fires, and when

Create fires both create hooks, once per row. The first line shows BeforeCreate ORD-10 and then AfterCreate ORD-10. For a slice of two orders the log reads BeforeCreate ORD-1 BeforeCreate ORD-2 AfterCreate ORD-1 ... AfterCreate ORD-2 ...: GORM runs all the before hooks, issues one multi row INSERT, then runs all the after hooks. The statement is a single round trip, and the hook code runs per element. A hook that does a query inside is an N plus one at that point, so keep hooks free of database calls where you can.

An error in a before hook stops the operation. The third line has no entries: BeforeCreate returned negative total, GORM aborted before the insert, and the AfterCreate hook never ran. The error comes back as .Error on the result. That is the useful side of hooks: a validation that is impossible to skip when you go through GORM.

Update paths are not equal. Four calls change a row, and the log shows which ran the hook:

CallFired BeforeUpdate
Model(&o).Update("notes", "a")yes
Model(&o).Updates(map[string]any{...})yes
Model(&o).UpdateColumn("notes", "c")no
Model(&Order{}).Where("id > 0").Update(...)yes, on an empty Order
Exec("update orders set ...")no

UpdateColumn and UpdateColumns are the documented way to skip hooks (and to skip updating updated_at). Raw SQL through Exec skips them too, because GORM does not parse it. So a rule that lives only in a hook is a rule that a maintenance script or a different code path can break without noticing.

The fourth row deserves a careful look. A bulk update with a Where and no loaded record fires BeforeUpdate on the zero value &Order{}. Inside the hook, o.ID is 0 and every field is empty. A hook that reads fields of the receiver, to check that the status may change for example, sees nothing, and passes. Hooks cannot enforce per row rules on bulk updates. If your hook assumes the receiver is a loaded row, guard it with if o.ID == 0 { return nil } and enforce the rule elsewhere for the bulk case.

AfterFind runs for every loaded row. First incremented the counter once, and Find of three orders ran the hook for each; the lab prints 1 1 3. A hook that does work per row loaded makes every list endpoint slower by that amount. Use it for cheap, pure fixes such as normalising a field, never for lookups.

The transaction problem

GORM wraps each write in a transaction, so AfterCreate runs before the commit. The last block of the lab makes that visible. It opened a transaction, created an order, which logged BeforeCreate ORD-99 and AfterCreate ORD-99 (still inside the transaction), then returned an error and rolled back. A count afterwards found 0 rows with total 99.

The hook ran, and its effects that live outside PostgreSQL happened for a row that never existed. In the lab that effect was a line in a slice. In a service it is an email, a message on a queue, a call to a payment API, a cache write or a metric. The customer gets a confirmation for an order that is not there.

The fix is structural: do not perform external side effects in a hook. Record the intent in the same transaction, and let a separate process perform it after the commit. The outbox pattern does exactly this, and the outbox article has a worked version. A cheaper variant for small services is to return the work from the service function and perform it after db.Transaction has returned nil.

Callbacks: the layer under hooks

Hooks are methods on a model. Callbacks are functions registered on the connection that run for every model, for example the counter in the Preload article. The API is db.Callback().Create().Before("gorm:create").Register("name", fn), with the same for Query, Update, Delete, Row and Raw. Callbacks suit cross cutting concerns where you need a view of the statement itself: tracing, metrics, a query counter, tenant filters. GORM's own plugins (OpenTelemetry, the DB resolver) are built from them.

Callbacks have the same boundaries as hooks. They run inside GORM, so they skip database/sql calls you make on the underlying *sql.DB. They also see only what GORM sees: a raw statement reaches Raw and Row callbacks as text.

When hooks are reasonable

  • Pure data rules on the model. Defaulting a field, normalising an email, building a slug from a title. No I/O, no queries, deterministic.
  • Validation that cannot be forgotten. Rejecting an invalid value, with the understanding that raw SQL bypasses it, so the database should also hold the real constraint: a check or not null.
  • ID and timestamp generation. A BeforeCreate that sets a UUID or ULID when the key is empty is a common, safe use.

When to avoid them

  • Anything that talks to another system.
  • Anything that queries the database, because it runs per row and inside the caller's transaction.
  • Business rules that must hold for bulk updates, because the hook runs on an empty receiver there.
  • Rules that scripts and migrations must also honour. Put those in a database constraint or a trigger.
  • Anything a reader of the call site would not guess. db.Create(&user) looks like an insert, and a hook that also creates a profile, writes an audit row and enqueues a welcome email makes it a workflow that is invisible where you read it.

The alternative is an explicit service function: CreateOrder(ctx, in) that validates, opens a transaction, inserts, writes the outbox row and returns. It is more lines than a hook, and every step is where a reader expects to find it.

Checklist

  • A hook fires per row for slice creates, and AfterFind per loaded row.
  • UpdateColumn, UpdateColumns and Exec skip update hooks.
  • A bulk update runs BeforeUpdate on an empty struct.
  • After hooks run before the commit, so never send mail, publish events or call APIs from them.
  • Keep hooks pure, fast and free of queries, and put real invariants in the database.

The hook list and its rules are in the GORM hooks documentation, and the plugin and callback API in the GORM plugin guide.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.