skip to content

When a helper wraps work in a *sql.Tx, how do you avoid losing the Commit or Rollback error?

level: seniorimportance: should knowfreq 42%

answer

  1. the last line is the risky one
  2. an unnamed result is invisible to defer
  3. the deferred closure needs to see the failure
  4. keep the cause, add the rollback failure
  5. one sentinel is expected and filtered out

basics

~20 s

Return tx.Commit()'s error to the caller rather than discarding it, and give the wrapper a named result so its deferred rollback can see the failure it is undoing and attach a genuine rollback error, ignoring only sql.ErrTxDone.

solid answer

~50 s

The failure to design against is a helper that ends with `tx.Commit()` on one line and `return nil` on the next: the caller is told the write succeeded when it never landed. So the wrapper's last statement is `return tx.Commit()`. Then give the function a **named** result — `(err error)` — so the deferred closure can read the error being returned: roll back only when `err != nil`, and if that rollback itself fails with something other than `sql.ErrTxDone`, join it onto the original error with `errors.Join` rather than replacing it. `sql.ErrTxDone` is filtered because it just means the transaction already ended, which is the normal success path. If the body panics, roll back and re-panic — never convert a panic into a nil error. The result is one reviewed function every write path goes through, so no branch can lose an error.

code

go · 10 lines
go
defer func() { _ = tx.Rollback() }()

if _, err := tx.ExecContext(ctx, debitSQL, amountMinor, fromID); err != nil {
	return err
}
if _, err := tx.ExecContext(ctx, creditSQL, amountMinor, toID); err != nil {
	return err
}
tx.Commit() // BUG: a failed commit is reported as success
return nil

go deeper

for a junior

The takeaway to hold onto: tx.Commit() returns an error and it must be returned to the caller, never dropped. A transaction that fails to commit did not happen.

for a middle

Explain how a deferred closure can inspect and replace a named result, and why sql.ErrTxDone is filtered out of the rollback error while other rollback failures are joined onto the original.

for a senior

Show the whole wrapper and defend each decision — named result, conditional rollback, errors.Join, re-panic — and describe the incident it prevents: a commit failure reported to the caller as success. Say what you would not put in it, such as blind retries.

for a principal

Argue for a single transaction wrapper as a codebase standard rather than a pattern people reimplement, and decide what it must never hide: context errors, panics, and the difference between a retryable serialisation failure and a constraint violation.

## The postmortem this pattern comes from A ledger service posts a debit and a credit inside one transaction, amounts in minor units. For weeks it reports success on every request. Then reconciliation finds a day where a handful of transfers were acknowledged to the caller and no rows exist. The code: ```go defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, debitSQL, amountMinor, fromID); err != nil { return err } tx.Commit() // error discarded return nil ``` The database was rejecting some commits — a deadlock victim, a serialisation failure, a connection dropped at exactly the wrong moment. `Commit` returned an error; the code returned `nil`. The deferred `tx.Rollback()` then ran, returned `sql.ErrTxDone` because the transaction was already finished, and was thrown away. Everything looked fine from Go's side, and the only visible trace was a report that did not balance. The lesson is narrow and important: **`tx.Commit()` is the operation that can fail, and it is the one people forget to check.** Every statement before it can succeed and the transaction can still not happen. ## The wrapper ```go func withTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) (err error) { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer func() { if p := recover(); p != nil { _ = tx.Rollback() panic(p) } if err != nil { if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) { err = errors.Join(err, rbErr) } } }() if err = fn(tx); err != nil { return err } return tx.Commit() } ``` Four decisions are encoded here, and each is worth being able to defend. **The named result `(err error)`.** A deferred closure can read and *modify* a named result parameter, because the result is a variable that lives until the function actually returns. With an unnamed `error` result the closure has no way to see what is being returned, so it cannot decide whether to roll back and cannot attach anything. This is the single reason the signature is written that way, and it is the detail interviewers probe. **Rolling back only when `err != nil`.** With the named result available, the rollback becomes conditional and intentional rather than a blanket safety net. It reads as "the work failed, so undo it". Note that the unconditional `defer func() { _ = tx.Rollback() }()` from the simple in-line shape is not wrong — it is just silent. The conditional version is what lets you report a rollback that genuinely failed. **`errors.Join`, not replacement.** If the rollback fails, the original error is still the interesting one — the rollback failure is context. `errors.Join` (Go 1.20) produces an error wrapping both, and `errors.Is` still matches either. Returning only the rollback error would hide the cause; ignoring it entirely would hide that the database connection is in trouble. **Filtering `sql.ErrTxDone` with `errors.Is`.** That sentinel means the transaction had already ended — because the commit ran, or because the context was cancelled and `database/sql` rolled back for you. It is expected, not a defect, and joining it onto every error would bury real signal in noise. `errors.Is` rather than `==` because a driver or an intermediate layer may wrap it. **Panic: roll back and re-panic.** The wrapper's job is to release the transaction, not to decide the program's fate. Recovering and returning an error turns a bug into a plausible-looking failure and loses the stack; recovering and returning nil is worse. Roll back, then `panic(p)` so the value continues up the stack to whatever layer is designed to handle it. ## How the call site reads ```go err := withTx(ctx, db, func(tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, debitSQL, amountMinor, fromID); err != nil { return err } _, err := tx.ExecContext(ctx, creditSQL, amountMinor, toID) return err }) ``` The body cannot forget to commit, cannot forget to roll back, and — because the closure only receives the `*sql.Tx` — has no easy way to reach the `*sql.DB` and accidentally run a statement outside the transaction. ## What a wrapper must not quietly do **Retries.** It is tempting to retry inside `withTx` on a serialisation or deadlock error. Be careful: the closure may have mutated in-memory state, appended to a slice, or sent on a channel, and re-running it is only safe if it is genuinely pure with respect to everything but the database. If you retry, make that a separate, explicitly named wrapper so the caller opts in. **Swallowing context errors.** A `Commit` that returns `context.DeadlineExceeded` means the transaction was abandoned. Callers often want to distinguish that from a constraint violation, so pass the error through unchanged and let them use `errors.Is`. **Logging instead of returning.** A helper that logs the commit error and returns nil recreates the original bug with a paper trail. The error belongs to the caller. ## The reviewer's checklist When you read a transaction block in a pull request, three questions catch nearly everything: is `Commit`'s error returned; is there exactly one place that ends the transaction on failure; and can any statement in the body reach a handle other than this `*sql.Tx`. If the answer to all three is good, the block is almost certainly correct.

  • Why does the wrapper need a named result parameter?
    Because the deferred closure runs before the function has finished returning, and only a *named* result is a variable it can read and reassign. That is how the defer decides to roll back at all — it inspects the error the function is about to return — and how it attaches a rollback failure to it. With an unnamed `error` result the closure is blind and can only roll back unconditionally and silently.
  • Should the wrapper recover from a panic raised inside the transaction body?
    Only far enough to roll back, then re-panic with the same value. The wrapper owes the transaction a clean ending; it does not own the decision about a programming bug. Converting the panic into a returned error loses the stack and makes a crash look like an ordinary failure, and returning nil after a panic is how corrupt state ships.
  • Why filter sql.ErrTxDone from the rollback error but report the others?
    `sql.ErrTxDone` only means the transaction had already ended — the commit ran, or the context was cancelled and database/sql rolled back for you. It carries no information. Any other rollback error means the rollback itself did not go through, which usually points at a broken connection and is exactly what the postmortem needs to see alongside the original failure.
  • Would you build retry-on-deadlock into this wrapper?
    Not into this one. Re-running the closure is safe only if it has no effects outside the database, and that is a property of each caller, not of the wrapper. Expose retry as a separate, explicitly named wrapper that callers opt into, so nobody gets their in-memory mutations executed twice by accident.

saying these in an interview costs you the question

  • Calls tx.Commit() and returns nil, dropping its error
  • Returns the rollback error and loses the original cause
  • Recovers a panic in the wrapper and returns an error or nil
  • Uses an unnamed result so the defer cannot see the error
  • Treats sql.ErrTxDone from the deferred rollback as a real failure
  • Logs the commit failure instead of returning it to the caller