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

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.
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)
}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: 0What 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:
| Call | Fired 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
checkornot null. - ID and timestamp generation. A
BeforeCreatethat 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
AfterFindper loaded row. UpdateColumn,UpdateColumnsandExecskip update hooks.- A bulk update runs
BeforeUpdateon 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.


