Why is a deferred tx.Rollback() safe right after db.BeginTx even when the transaction commits?
answer
- a transaction can only end once
- the second ending is a no-op
- there is a sentinel error for it
- sql.ErrTxDone, and it is discarded
- defer goes after the BeginTx error check
basics
~10 sRollback on an already-committed *sql.Tx does nothing: it returns sql.ErrTxDone and leaves the commit intact. The deferred call exists as a safety net for early returns and panics, so its error is deliberately ignored.
solid answer
~40 sA `*sql.Tx` can only end once. `tx.Commit()` and `tx.Rollback()` both mark it done, and any later call on it returns `sql.ErrTxDone` ("sql: transaction has already been committed or rolled back") without touching the database. So the idiom is: begin, check the error, then `defer func() { _ = tx.Rollback() }()`, do the work, and `return tx.Commit()`. On every failure path — an early `return`, a validation error, a panic — the deferred rollback is what actually ends the transaction and releases its connection; on the success path it is a harmless no-op whose `sql.ErrTxDone` you discard. The one rule is ordering: register the defer *after* checking `BeginTx`'s error, because `BeginTx` returns a nil `*sql.Tx` on failure and the deferred call would panic.
code
go · 13 linestx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }() // no-op once Commit has run
if _, err = tx.ExecContext(ctx, debitSQL, amountMinor, fromID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, creditSQL, amountMinor, toID); err != nil {
return err
}
return tx.Commit()go deeper
Be ready to write the four-line skeleton from memory: BeginTx, check the error, defer the rollback, commit last. Say out loud that the deferred rollback does nothing after a successful commit.
Explain the done flag inside *sql.Tx: the first of Commit or Rollback wins, and every later call returns sql.ErrTxDone without reaching the database. Mention filtering that sentinel with errors.Is when you do want rollback failures logged.
Show why the defer is a correctness device, not style: it is the only thing that ends the transaction on a branch someone adds later. Be able to describe what an unfinished transaction costs — a pinned connection and held row locks until the context is done.
Frame it as a codebase-wide rule rather than a per-function habit: one reviewed transaction helper that every write path goes through, so no branch can be added without a rollback. Argue for it against the cost of a bespoke begin/commit block in every repository method.
## What a *sql.Tx is `db.BeginTx(ctx, opts)` returns a `*sql.Tx`: a handle to one in-progress database transaction. Unlike `*sql.DB`, which is a pool of connections, a `*sql.Tx` has taken exactly one connection out of that pool and holds it until the transaction ends. That connection is not available to anyone else in the process for the whole life of the transaction, which is why *ending* the transaction is not optional bookkeeping — it is how the connection gets back. A transaction ends in exactly one of two ways: `tx.Commit()` or `tx.Rollback()`. Both are terminal. ## Why the deferred rollback is harmless after a commit `database/sql` keeps a done flag on the `Tx`. Whichever of `Commit` or `Rollback` runs first flips it atomically and performs the real work on the driver; every subsequent call on that same `Tx` — `Rollback`, `Commit`, `ExecContext`, `QueryContext` — short-circuits and returns `sql.ErrTxDone`, whose message is "sql: transaction has already been committed or rolled back". Nothing is sent to the database. There is no risk whatsoever that a deferred rollback undoes a commit that already succeeded; by the time the defer runs, the `Tx` is inert. That property is what makes the standard shape work: ```go tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() // ... statements, any of which may return early ... return tx.Commit() ``` The deferred rollback is a *catch-all*. Go has no `finally`, and a function that begins a transaction usually has several error paths. Writing an explicit `tx.Rollback()` before each `return` is exactly the kind of repetition that eventually gets forgotten in one branch during a refactor — and the branch that forgets does not fail loudly. It leaks a connection and holds the database's locks. The defer removes that class of bug entirely: registered once, it runs on *every* exit, including a panic unwinding through the frame. ## Why the error is discarded, and when it should not be `_ = tx.Rollback()` is written with an explicit blank assignment (rather than the bare `defer tx.Rollback()`) mostly so a reader — and an unused-error check in review — can see the discard is intentional. On the success path the error is always `sql.ErrTxDone` and means nothing. If you do want to know about genuine rollback failures — a dropped connection, a driver error — filter the expected value out: ```go defer func() { if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) { log.Printf("rollback failed: %v", err) } }() ``` `errors.Is` is the right comparison because a driver may wrap the sentinel. ## Ordering: check the error first ```go tx, err := db.BeginTx(ctx, nil) defer tx.Rollback() // WRONG: tx is nil if BeginTx failed if err != nil { return err } ``` `BeginTx` returns `(nil, err)` when it cannot get a connection or the driver refuses the options. Calling a method on that nil `*sql.Tx` dereferences it and panics. Always: begin, check, then defer. ## What happens if neither Commit nor Rollback is ever called The transaction stays open. The database keeps its locks and its undo state, and the Go process keeps the connection checked out of the pool. `database/sql` will end it for you only when the context you passed to `BeginTx` is done — so with a cancellable request context it eventually rolls back, and with `context.Background()` it is held for the life of the process. In a payments ledger that posts a debit row and a credit row inside one transaction, a forgotten rollback on the debit-failed path means one connection is pinned per bad request until the process restarts, and the rows those two statements touched stay locked against everybody else. That is the failure the deferred rollback is there to make impossible. ## Related shapes There is no savepoint API on `*sql.Tx`: it has no `Begin` or `Savepoint` method, so there are no nested transactions in `database/sql`. If the engine supports savepoints you issue `SAVEPOINT` / `ROLLBACK TO` yourself with `tx.ExecContext`, and the outer `Commit`/`Rollback` still ends the transaction. Finally, note that `db.Begin()` is just `db.BeginTx(context.Background(), nil)`. Prefer `BeginTx` with a real context so the transaction is not immortal.
- Why must the deferred rollback be registered after checking BeginTx's error rather than before?`db.BeginTx` returns a nil `*sql.Tx` together with the error when it cannot start a transaction. Deferring a method call on that nil pointer means the deferred call dereferences nil and panics as the function returns — masking the real error with a crash. Check `err`, return early, and only then arm the defer.
- What happens if a function begins a transaction and returns without ever calling Commit or Rollback?The transaction stays open: the database holds its locks and undo state, and the connection stays checked out of the pool. `database/sql` ends it only when the context passed to `BeginTx` is done, so with `context.Background()` the connection is pinned for the life of the process. It is a permanent leak, not a slow one.
- Does database/sql support nested transactions or savepoints?No. `*sql.Tx` has no `Begin` or `Savepoint` method, so you cannot nest transactions through the package. If your engine supports savepoints you issue `SAVEPOINT name` and `ROLLBACK TO name` yourself via `tx.ExecContext`; `database/sql` treats them as ordinary statements and the outer `Commit` or `Rollback` still ends the whole transaction.
It is like a fire alarm you arm on the way in: if you left the building normally it has nothing to do, and pulling it afterwards does not un-leave you.
saying these in an interview costs you the question
- Says the deferred Rollback can undo a successful commit
- Registers defer tx.Rollback() before checking BeginTx's error
- Reads sql.ErrTxDone as proof the commit failed
- Writes an explicit Rollback on every error branch instead of one defer
- Leaves an error path with no Commit and no Rollback at all