Transactions
A transaction in LiteORM is a *liteorm.BoundTx you get from db.Begin(ctx). It satisfies the same liteorm.Session interface as *liteorm.DB, so every builder, repository, and helper in both front-ends works on it unchanged — you just pass the transaction where you’d otherwise pass the database.
For exhaustive API detail, see the reference at pkg.go.dev/liteorm.org.
Begin, commit, rollback
Section titled “Begin, commit, rollback”db.Begin(ctx) starts a transaction. Commit it with .Commit(ctx) to persist your work, or .Rollback(ctx) to discard it. A deferred rollback is the safe idiom — it’s a no-op once you’ve committed.
tx, err := db.Begin(ctx)if err != nil { return err}defer tx.Rollback(ctx) // harmless after a successful Commit
repo := query.NewRepo[Account](tx)if err := repo.Insert(ctx, &from); err != nil { return err}if err := repo.Insert(ctx, &to); err != nil { return err}
return tx.Commit(ctx)Because the transaction is a Session, both front-ends run inside it the same way they run on the database:
// query builder, inside the txhot, err := query.Select[Product](tx). Filter(query.Col[bool]("active").Eq(true)). All(ctx)
// orm repository, inside the txposts := orm.NewRepo[Post](tx)err = posts.Create(ctx, &p)The closure helper
Section titled “The closure helper”liteorm.Transaction runs a function in a transaction and handles the commit, rollback, and panic-rollback for you — the manual dance above, done correctly once. It commits when the function returns nil, rolls back when it returns an error (returning that error), and rolls back and re-raises if the function panics.
err := liteorm.Transaction(ctx, db, func(tx *liteorm.BoundTx) error { if err := orm.NewRepo[Account](tx).Update(ctx, &from); err != nil { return err // rolls back } return orm.NewRepo[Account](tx).Update(ctx, &to)})Retrying on contention
Section titled “Retrying on contention”Under serializable (or repeatable read) isolation, a transaction can fail with a serialization or deadlock error that is safe to retry — the database is telling you to run the whole unit of work again. liteorm.TransactionRetry does exactly that, re-running the function on a fresh transaction while the error is a retryable one (liteorm.IsRetryable — a deadlock or serialization failure, normalized identically across every backend), and returning any other error (or success) immediately:
err := liteorm.TransactionRetry(ctx, db, liteorm.RetryPolicy{ Max: 5, Backoff: func(attempt int) time.Duration { return time.Duration(attempt) * 10 * time.Millisecond },}, func(tx *liteorm.BoundTx) error { return transfer(ctx, tx, from, to, amount)})Each attempt is a new transaction, so the function must be safe to run more than once. A nil Backoff retries immediately; a cancelled context stops the wait between attempts.
Nested transactions are savepoints
Section titled “Nested transactions are savepoints”Calling .Begin(ctx) on a transaction opens a nested transaction implemented as a SAVEPOINT. Rolling back the nested handle undoes only the work done since that savepoint; the outer transaction keeps everything before it. This lets you make part of a unit of work optional without abandoning the whole thing.
tx, err := db.Begin(ctx)if err != nil { return err}
query.NewRepo[Product](tx).Insert(ctx, &Product{Name: "Keeper"})
sp, err := tx.Begin(ctx) // nested = savepointif err != nil { return err}query.NewRepo[Product](sp).Insert(ctx, &Product{Name: "Doomed"})sp.Rollback(ctx) // undo just the savepoint — "Keeper" survives
tx.Commit(ctx)After this runs, Keeper is persisted and Doomed is not.
query and orm interoperate on one transaction
Section titled “query and orm interoperate on one transaction”The two front-ends share LiteORM’s core, so a value written through one is immediately visible to the other on the same transaction — no flush, no second connection, no surprises. Write declaratively with orm and read with the explicit query builder (or the reverse) inside a single atomic unit:
tx, err := db.Begin(ctx)if err != nil { return err}defer tx.Rollback(ctx)
// Write via the orm repository.hopper := Author{Name: "Hopper", Email: "hopper@x.io"}if err := orm.NewRepo[Author](tx).Create(ctx, &hopper); err != nil { return err}
// Read it back via the query builder, on the same tx, before commit.back, err := query.Select[Author](tx). Filter(query.Col[int64]("id").Eq(hopper.ID)). First(ctx)if err != nil { return err}fmt.Println(back.Name) // "Hopper"
return tx.Commit(ctx)Errors during a transaction
Section titled “Errors during a transaction”The normalized error values work the same inside a transaction as outside it — check them with errors.Is and decide whether to roll back:
if err := repo.Insert(ctx, &dup); err != nil { if errors.Is(err, liteorm.ErrUniqueViolation) { // handle the conflict; the deferred Rollback will discard the tx return err } return err}The normalized set spans liteorm.ErrNoRows, ErrUniqueViolation, ErrForeignKey, ErrNotNull, ErrCheck, ErrDeadlock, and ErrSerialization, consistent across every backend — so retry-on-ErrSerialization logic you write for one database works on all of them.
Where to next
Section titled “Where to next”- The query front-end — the builder and repository you run inside a tx.
- The orm front-end — declarative models, equally transaction-aware.
- Hooks — lifecycle callbacks fire inside the transaction that triggered them.