Transactions and post-commit hooks
database.Atomic runs related writes in one PostgreSQL transaction, like Django’s
transaction.atomic. database.OnCommit schedules work that must happen only after
those writes commit, like Django’s transaction.on_commit.
Use OnCommit for sending an email, enqueueing a job on another system or
invalidating a cache. 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
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
})database.Atomic(ctx, db, fn) begins a transaction and passes it to fn.
- When
fnreturns nil, it commits. - When
fnreturns an error or panics, everything rolls back. The error or panic propagates unchanged. - Use the supplied
txfor every query.
It wraps GORM’s db.Transaction. It accepts the same optional *sql.TxOptions,
for example &sql.TxOptions{Isolation: sql.LevelSerializable}.
#Nested calls are savepoints
Pass a tx from an enclosing Atomic, and Atomic creates a savepoint instead of
a new transaction. If the inner function fails, only its writes roll back. 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))
})
})#Hooks in model forms
These saves already run through database.Atomic:
- model form saves (
Bound.CreateandUpdate), - administration saves,
- formset saves (from v0.2.4),
- account service operations in
contrib/accounts.
So a model-form hook 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. Do not capture models that later code may change.
In a formset Save, hooks run after the whole formset commits. They are dropped
if any row fails.
#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 returns 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.
Warning
A crash between commit and hook leaves the hook’s work undone. When the effect
must not be lost, use a transactional outbox instead: insert a job in the same
transaction with jobs.InsertTx.
#Transactions started elsewhere
Atomic tracks the transactions it begins. Inside a transaction that GORM or your
code began directly:
Atomicstill creates savepoints,OnCommitreturnsErrNoTransaction.
So start the outermost transaction with database.Atomic wherever hooks may be
registered. This includes before model.Form.InTransaction(tx).
If GORM’s DisableNestedTransaction is set, nested calls create no savepoint.
Their writes and hooks stand or fall with the enclosing transaction.
#Test hooks
Hooks run on commit. Test them with a committed transaction in a disposable
database from apptest/postgres.New. Do not use 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.