GORM Transactions and Savepoints Used Correctly in Services
GORM transactions on PostgreSQL: the Transaction closure, panics, nested calls as savepoints, SavePoint and RollbackTo, and the aborted transaction trap

A transaction is the unit of work in a service: either the whole business operation happened or none of it did. GORM gives you three ways to express that, and they differ in what happens on an error, on a panic and when you nest one inside another. This article runs each against PostgreSQL 18.6 with a money transfer, because a transfer is the example where a half done operation is plainly wrong, and then reads the output to show where each form is safe and where it is 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 assumes the basics from the GORM tutorial. The same ideas at the driver level are in transactions with database/sql and pgx transactions, and isolation levels are in their own article.
The three forms
db.Transaction(func(tx *gorm.DB) error { ... }). The closure form. GORM begins, runs your function, commits if it returns nil, and rolls back if it returns an error or panics. Use this by default.tx := db.Begin()withtx.Commit()andtx.Rollback(). The manual form. You own every exit path.SavePointandRollbackToon an open transaction, for partial rollback.
Everything inside a transaction must use the tx value the closure received, not the outer db. A call on db inside the closure runs on a different connection, outside the transaction, and its effects are neither rolled back nor visible to the transaction's own statements. This is the most common transaction bug in GORM code, and the compiler cannot catch it. Name the parameter tx and shadow nothing.
The lab
The program keeps two accounts and a transfer function that checks the balance, debits one account and credits the other. Each numbered block runs the function in a different transaction shape and prints the balances afterwards:
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 Account struct {
ID uint
Name string `gorm:"uniqueIndex"`
Balance int
}
func balances(db *gorm.DB) string {
var as []Account
db.Order("id").Find(&as)
s := ""
for _, a := range as {
s += fmt.Sprintf("%s=%d ", a.Name, a.Balance)
}
return s
}
func transfer(tx *gorm.DB, from, to string, amount int) error {
var f Account
if err := tx.Where("name = ?", from).Take(&f).Error; err != nil {
return err
}
if f.Balance < amount {
return errors.New("insufficient funds")
}
if err := tx.Model(&Account{}).Where("name = ?", from).Update("balance", gorm.Expr("balance - ?", amount)).Error; err != nil {
return err
}
return tx.Model(&Account{}).Where("name = ?", to).Update("balance", gorm.Expr("balance + ?", amount)).Error
}
func main() {
db := connect("l0710", nil)
must(db.AutoMigrate(&Account{}))
db.Create(&[]Account{{Name: "ada", Balance: 100}, {Name: "bob", Balance: 50}})
fmt.Println("start: ", balances(db))
// 1. The function form commits on nil and rolls back on any error.
err := db.Transaction(func(tx *gorm.DB) error { return transfer(tx, "ada", "bob", 30) })
fmt.Println("commit: ", balances(db), "err:", err)
err = db.Transaction(func(tx *gorm.DB) error {
if err := transfer(tx, "ada", "bob", 20); err != nil {
return err
}
return transfer(tx, "bob", "ada", 500) // fails after the first leg ran
})
fmt.Println("rollback:", balances(db), "err:", err)
// 2. A panic inside the function also rolls back.
func() {
defer func() { recover() }()
db.Transaction(func(tx *gorm.DB) error {
transfer(tx, "ada", "bob", 10)
panic("boom")
})
}()
fmt.Println("panic: ", balances(db))
// 3. Nested Transaction calls are savepoints: the inner failure undoes only the inner work.
err = db.Transaction(func(tx *gorm.DB) error {
transfer(tx, "ada", "bob", 5)
inner := tx.Transaction(func(tx2 *gorm.DB) error {
transfer(tx2, "ada", "bob", 15)
return errors.New("inner failed")
})
fmt.Println(" inner error:", inner)
return nil
})
fmt.Println("nested: ", balances(db), "err:", err)
// 4. Manual savepoints, when the control flow does not fit a closure.
tx := db.Begin()
transfer(tx, "ada", "bob", 1)
tx.SavePoint("after_one")
transfer(tx, "ada", "bob", 2)
tx.RollbackTo("after_one")
tx.Commit()
fmt.Println("savepoint:", balances(db))
// 5. Manual Begin: forgetting Rollback leaves the connection checked out.
tx = db.Begin()
transfer(tx, "ada", "bob", 1)
tx.Rollback()
fmt.Println("manual rollback:", balances(db))
// 6. A statement that fails inside a PostgreSQL transaction poisons the rest of it.
err = db.Transaction(func(tx *gorm.DB) error {
dup := tx.Create(&Account{Name: "ada"}).Error // unique violation
fmt.Println(" duplicate:", dup != nil)
after := tx.Model(&Account{}).Where("name = ?", "bob").Update("balance", 1).Error
fmt.Println(" next statement:", after)
return nil
})
fmt.Println("poisoned commit error:", err)
fmt.Println("final: ", balances(db))
}start: ada=100 bob=50
commit: ada=70 bob=80 err: <nil>
rollback: ada=70 bob=80 err: insufficient funds
panic: ada=70 bob=80
inner error: inner failed
nested: ada=65 bob=85 err: <nil>
savepoint: ada=64 bob=86
manual rollback: ada=64 bob=86
duplicate: true
next statement: ERROR: current transaction is aborted, commands ignored until end of transaction block (SQLSTATE 25P02)
poisoned commit error: commit unexpectedly resulted in rollback
final: ada=64 bob=86Commit and rollback
The start state was ada=100 bob=50. The first closure moved 30 and returned nil, so the output after it is ada=70 bob=80: committed.
The second closure ran two transfers. The first leg moved 20 from ada to bob. The second tried to move 500 from bob back, found an insufficient balance and returned insufficient funds. The balances after it are still ada=70 bob=80, and the error was returned to the caller. The first leg's update was applied inside the transaction and then undone, which is the entire point of the closure form. If the two legs had run on db directly, bob would have kept the extra 20.
The rule for the closure is to return errors and never swallow them. return err rolls back. return nil after logging an error commits whatever ran before it.
A panic rolls back too
The second numbered block called panic("boom") inside the closure after moving 10. The balances are unchanged, ada=70 bob=80. GORM recovers the panic, rolls back, and re-raises it, so the recover() in the lab is the caller's. In an HTTP server the standard library's handler recovers panics per request, and your transaction has already been rolled back by then. You do not need your own defer tx.Rollback() inside the closure.
Nested transactions are savepoints
The third numbered block is the one to read twice. It starts a transaction, moves 5, then calls tx.Transaction(...) on the same tx, and in the inner closure moves 15 and returns an error. The output shows inner error: inner failed, and the balances are ada=65 bob=85: the 5 survived and the 15 was undone, and the outer transaction returned nil and committed.
GORM implements a nested Transaction call as SAVEPOINT, RELEASE SAVEPOINT on success and ROLLBACK TO SAVEPOINT on failure. That is the behaviour you want when an inner function is meant to be atomic on its own, and it can surprise you when you expected the failure to abort everything. Two consequences:
- A function that opens its own
Transactionis safe to call inside another one. The inner failure is returned as an error to the caller, and the caller decides what to do. Library style store methods can wrap themselves inTransactionwithout knowing whether a caller already started one. - If the caller ignores the inner error, the outer transaction commits the rest. The lab did exactly that, with
return nil. To make an inner failure abort the outer unit, return it.
The setting DisableNestedTransaction in the config turns the savepoint behaviour off, in which case the inner call joins the outer transaction. Leave it on unless you have a reason.
Manual savepoints
The fourth numbered block shows the manual API. After one transfer of 1, tx.SavePoint("after_one") marked a point, a second transfer of 2 ran, and tx.RollbackTo("after_one") undid only that second transfer. After Commit, the output is ada=64 bob=86: the first 1 moved and the 2 did not. Use this when the logic is a loop that tries items and skips the ones that fail, a bulk import where one bad row should not discard the rest, for example. Each iteration sets a savepoint, runs, and rolls back to it on failure.
Manual Begin
db.Begin() returns a *gorm.DB bound to a transaction. You must call Commit or Rollback on every path, including error returns and panics, or the connection stays checked out of the pool and the locks it holds stay too. The standard pattern is:
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
if err := step(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit().ErrorThat is the closure form written by hand, and it is the reason to use the closure. The fifth block in the lab (manual rollback) calls Rollback and shows the balances unchanged. Reach for Begin when the transaction has to span several functions that cannot share one closure, such as a middleware that begins on a request and commits on a successful response, and test the error paths of that code.
Commit can fail too. Check tx.Commit().Error, as the pattern above does. A commit can fail with a serialization error or a deferred constraint check, and ignoring it reports success for work that was not saved.
The aborted transaction
The last block is a PostgreSQL behaviour that catches everyone once. Inside a transaction, the lab inserted a duplicate account name. The unique index rejected it (duplicate: true). The code continued and ran an unrelated update. That update failed too:
ERROR: current transaction is aborted, commands ignored until end of transaction block (SQLSTATE 25P02)
and when the closure returned nil, Commit returned commit unexpectedly resulted in rollback. In PostgreSQL, any error inside a transaction aborts it. Every later statement is rejected until you roll back, and a COMMIT of an aborted transaction is a rollback. The final balances were unchanged.
This means you cannot catch a constraint error in the middle of a transaction, handle it and carry on, unless you wrapped the risky statement in a savepoint. The "insert if not present" pattern is the common case. Writing tx.Create(&row) and checking for a unique violation to decide what to do next breaks the transaction. The options:
clause.OnConflict{DoNothing: true}makes the statement succeed without an error, andRowsAffectedtells you whether it inserted.tx.Transaction(func(tx2 *gorm.DB) error { return tx2.Create(&row).Error })isolates the failure in a savepoint, and the outer transaction survives.FirstOrCreatefor the find or create case.
MySQL behaves differently: a failed statement does not abort the transaction. Code that works on MySQL by accident can break when moved to PostgreSQL, so test constraint handling against the production database engine.
Rules for services
- Own the transaction in one layer. Put it in the service or use case that represents the business operation, not in each repository method, and pass
txdown. - Keep it short. A transaction holds locks and a connection. Do not call external services, send mail or wait on a channel inside one. Do the slow work before or after.
- Use the context.
db.WithContext(ctx).Transaction(...)ties the whole transaction to the request, so a cancelled request rolls it back. - Retry on serialization and deadlock errors (SQLSTATE 40001 and 40P01) when you use higher isolation levels, with the whole closure re-run.
- Test the failure path. The rollback closure in the first block is the test that matters: make the second step fail and assert that the first left no trace.
Side effects inside hooks deserve special care, because a hook runs inside the transaction and its effect cannot be rolled back. The hooks article shows that with a measured example.
Checklist
- Prefer
db.Transactionand return every error from the closure. - Use only
txinside the closure, never the outerdb. - A nested
Transactionis a savepoint; the caller must return the inner error to abort the outer work. - Do not continue after a failed statement in PostgreSQL, unless the statement ran inside a savepoint or used
ON CONFLICT. - Check the error from
Commit.
The GORM behaviour is documented in the transactions guide, and the aborted transaction rule in the PostgreSQL SAVEPOINT reference.


