Transactions and post-commit hooks
Run related writes in one PostgreSQL transaction with database.Atomic, and
schedule work that must happen only after those writes are committed — sending
an email, enqueueing a job on another system, invalidating a cache — with
database.OnCommit. A rolled-back transaction never sends the email.
#Ask your agent
In my Cicadel project, send a welcome email after a new member is saved. Use
the model form’s AfterSave hook to register a database.OnCommit callback,
so the email goes out only after the transaction commits. Add a PostgreSQL
test showing that a rejected save sends nothing.
#Run writes atomically
database.Atomic(ctx, db, fn) begins a transaction, passes it to fn and
commits when fn returns nil. An error or panic rolls everything back; the
error (or panic) propagates unchanged. Use the supplied tx for every query.
err := database.Atomic(r.Context(), db, func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
return tx.Model(&stock).Where("id = ?", order.ItemID).
Update("count", gorm.Expr("count - 1")).Error
})It wraps GORM’s db.Transaction and accepts the same optional
*sql.TxOptions (for example &sql.TxOptions{Isolation: sql.LevelSerializable}).
#Nested calls are savepoints
Called with a tx from an enclosing Atomic, Atomic creates a savepoint
instead of a new transaction. If the inner function fails, only its writes roll
back and the outer function decides what to do with the error:
err := database.Atomic(ctx, db, func(tx *gorm.DB) error {
tx.Create(&invoice)
if err := database.Atomic(ctx, tx, func(tx *gorm.DB) error {
return tx.Create(&optionalDiscount).Error
}); err != nil {
log.Printf("discount skipped: %v", err) // invoice still commits
}
return nil
})#Run code after commit
Register a hook inside the transaction:
err := database.Atomic(ctx, db, func(tx *gorm.DB) error {
if err := tx.Create(&member).Error; err != nil {
return err
}
return database.OnCommit(tx, func(ctx context.Context) error {
return sender.Send(ctx, welcomeMessage(member.Email))
})
})Model form saves (Bound.Create/Update) and administration saves run
through database.Atomic, so model-form hooks can register directly:
form, err := model.New[Member](db, model.Config[Member]{
Fields: []model.Field{{Name: "Email", Required: true}},
AfterSave: func(ctx context.Context, tx *gorm.DB, m *Member) error {
to := m.Email
return database.OnCommit(tx, func(ctx context.Context) error {
return sender.Send(ctx, welcomeMessage(to))
})
},
})Copy the values a hook needs, as above, instead of capturing models that later code may change.
#Semantics
| Situation | Behavior |
|---|---|
| Outermost transaction commits | Hooks run in registration order, after commit and before Atomic returns. |
fn returns an error, panics, or the commit fails | No hook runs; every hook registered in the transaction is discarded. |
A savepoint (nested Atomic) fails | Hooks registered in it, and in savepoints inside it, are discarded. Hooks registered before or after it in the enclosing transaction survive. |
| A savepoint succeeds | Its hooks join the enclosing transaction and run only when the outermost transaction commits. |
OnCommit outside an Atomic transaction | Returns database.ErrNoTransaction; the hook is never run. This includes transactions from db.Begin, a raw db.Transaction and apptest/postgres.Transaction, whose commit Cicadel cannot observe. |
| A hook returns an error or panics | The commit stands. The failure (with the panic’s stack) is logged with slog.Default() at error level, later hooks still run and Atomic returns nil. |
ctx is cancelled before commit | PostgreSQL rolls the transaction back; Atomic returns the error and no hook runs. |
ctx is cancelled after commit | Hooks still run. They receive context.WithoutCancel(ctx): request values (such as the request ID) remain, cancellation and deadline do not. Set your own timeout in a hook that needs one. |
| Concurrent transactions | Each transaction has its own hooks; they never mix. |
Hooks run after the connection is returned to the pool, so they must not use
tx. A hook that needs to write starts a new database.Atomic. Registering on
a finished transaction returns ErrNoTransaction.
Because hooks run after commit, a crash between commit and hook leaves the work
undone. When the effect must not be lost, insert a job in the same transaction
with jobs.InsertTx (a transactional outbox) instead of a hook.
#Transactions started elsewhere
Atomic tracks the transactions it begins. Within a transaction that GORM or
your code began directly, Atomic still creates savepoints, but OnCommit
returns ErrNoTransaction. Start the outermost transaction with
database.Atomic wherever hooks may be registered — including before
model.Form.InTransaction(tx). If GORM’s DisableNestedTransaction is set,
nested calls create no savepoint, so their writes and hooks stand or fall with
the enclosing transaction.
Formset saves (from v0.2.4) also run through database.Atomic, so model-form
hooks that call OnCommit during a formset Save run after the whole formset
commits, and are dropped if any row fails.
#Test hooks
Hooks run on commit, so test them with a committed transaction in a disposable
database from apptest/postgres.New, not inside postgres.Transaction (which
always rolls back):
func TestWelcomeEmail(t *testing.T) {
db := postgres.New(t).DB
outbox := &mail.Capture{}
// ... apply migrations, build the form with mail.Sender{Transport: outbox}
if _, err := form.Bind(url.Values{"email": {"ada@example.com"}}).Create(ctx, Member{}); err != nil {
t.Fatal(err)
}
if len(outbox.Messages()) != 1 {
t.Fatal("welcome email not sent after commit")
}
}See model forms, email and testing.