What happens to an open transaction when the context passed to db.BeginTx is cancelled?
answer
- the context is not just for Begin
- something is watching it in the background
- abandonment happens without your code
- Commit afterwards cannot succeed
- two possible errors depending on timing
basics
~20 sdatabase/sql watches that context for the life of the transaction and rolls it back automatically when the context is done. A later tx.Commit() then fails, returning the context's error or sql.ErrTxDone once the rollback has landed.
solid answer
~40 sThe context you hand to `db.BeginTx` is not just for starting the transaction — it governs the whole transaction. `database/sql` watches it in the background, and when it is cancelled or its deadline passes, the package issues the rollback for you and releases the transaction's connection. Any later call on that `*sql.Tx` fails: `tx.Commit()` returns the context's error, or `sql.ErrTxDone` if the automatic rollback already completed. That is usually what you want in an HTTP handler — if the client goes away, `r.Context()` is cancelled and the half-written work is abandoned rather than left holding locks. It also means you must not pass a request context to work that has to finish regardless: for that, derive a fresh context with its own timeout, or use `context.WithoutCancel` to keep the values while dropping the cancellation.
code
go · 13 linesctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// ... reads ...
if err := tx.Commit(); err != nil {
// context.DeadlineExceeded, or sql.ErrTxDone if the rollback already ran
return err
}go deeper
Know that the context you pass to BeginTx governs the whole transaction, not just its start, and that cancelling it aborts the work. Always pass a real context rather than context.Background().
Describe the background watcher: on cancellation database/sql issues the rollback itself and releases the connection, after which Commit returns the context's error or sql.ErrTxDone. Also name the two fields of sql.TxOptions.
Judge which operations should die with the caller and which must not. Show the WithoutCancel plus own-timeout pattern for writes that must complete, and pair it with idempotency so a client retry is safe.
Set the policy for how deadlines flow from the edge into the data layer: who chooses the transaction's time budget, whether it is derived from the request or fixed per operation, and what the service promises a caller whose connection dropped mid-write.
## The context outlives the Begin call Most `database/sql` methods take a context that scopes one operation: `db.ExecContext`'s context covers that statement. `db.BeginTx` is different. Its documentation is explicit: the provided context is used until the transaction is committed or rolled back, and if the context is cancelled the package will roll the transaction back. The transaction is bound to the context's lifetime. Internally `database/sql` starts a small watcher that waits on the context's `Done` channel. When it fires, the package marks the `Tx` done and issues the rollback on the transaction's connection, then releases the transaction's hold on it. This happens whether or not your goroutine is currently inside a `tx.ExecContext` call. ## What your code sees afterwards Two different errors, depending on timing, and both are correct: - If you call `tx.Commit()` after the context is done but before the background rollback has finished, `Commit` sees the cancelled context and returns `ctx.Err()` — `context.Canceled` or `context.DeadlineExceeded`. - If the rollback has already completed, the transaction is marked done and `Commit` returns `sql.ErrTxDone`. So error handling on a transaction's `Commit` must not assume a single sentinel. Use `errors.Is(err, context.DeadlineExceeded)` when you specifically want to distinguish a timeout from a database rejection, and treat anything non-nil from `Commit` as "this transaction did not happen". The deferred `tx.Rollback()` you armed after `BeginTx` still runs, still returns `sql.ErrTxDone`, and is still correctly ignored. The automatic rollback and the deferred one cannot both take effect — the done flag makes the first one win. ## Why this is the right default A long-running transaction is expensive on the server: it holds row locks and pins undo or version data. If the caller has walked away — an HTTP client disconnected, an upstream deadline blew — the worst outcome is a transaction that sits open until someone notices. Tying it to the context means abandonment is automatic. In a ledger service where each posting is a short transaction under a per-request deadline, this is the mechanism that stops a slow database from accumulating open transactions request by request. It is also why passing `context.Background()` to `BeginTx` deserves a second look in review: it produces a transaction with no automatic end at all, so a code path that forgets both `Commit` and `Rollback` leaks a connection and a set of locks for the life of the process. ## The trap: work that must finish The flip side is real. If you begin a transaction with the incoming request's context and then the client hangs up mid-write, your transaction is rolled back — even if it was the very last thing that mattered about that request. When the write must complete regardless of the caller: ```go // Keep request-scoped values, drop the caller's cancellation. bg := context.WithoutCancel(r.Context()) ctx, cancel := context.WithTimeout(bg, 5*time.Second) defer cancel() tx, err := db.BeginTx(ctx, nil) ``` `context.WithoutCancel` (Go 1.21) returns a context that keeps the parent's values but is never cancelled by the parent; wrapping it in your own `WithTimeout` gives the transaction a bound that is yours rather than the client's. Never reach for a bare `context.Background()` here — you would lose the deadline as well as the cancellation. The judgement call is which behaviour the operation wants. A read, a search, a report: cancel it, nobody is waiting. A payment posting that the caller has already been told is in flight: give it its own deadline, and make the operation idempotent so a retry is safe. ## The options argument `BeginTx`'s second parameter is a `*sql.TxOptions` with two fields: `Isolation` (a `sql.IsolationLevel` such as `sql.LevelDefault` or `sql.LevelSerializable`) and `ReadOnly bool`. Passing `nil` means the driver's default isolation and a read-write transaction. Drivers reject levels they do not support, and that rejection surfaces as an error from `BeginTx` itself. `ReadOnly` is worth using for reporting transactions: it lets the engine refuse writes and, on some engines, take cheaper locks — a cheap guard against a helper accidentally writing inside what was meant to be a read. ## What it does not give you Cancellation is not a retry mechanism and not a guarantee about ordering. The rollback is asynchronous relative to your goroutine, so you cannot assume the database has finished rolling back at the moment `Commit` returns its error. If your next step depends on the rows being gone, verify it rather than assume it.
- What does the *sql.TxOptions argument to db.BeginTx configure?Two things: `Isolation`, a `sql.IsolationLevel` such as `sql.LevelDefault` or `sql.LevelSerializable`, and `ReadOnly bool`. Passing `nil` takes the driver's defaults — the connection's usual isolation, read-write. A driver that cannot honour the requested level returns an error from `BeginTx` itself, so unsupported settings fail loudly at Begin rather than silently downgrading.
- You need a write to finish even if the HTTP client disconnects. What context do you give BeginTx?Not `r.Context()`, or the disconnect rolls you back. Wrap it with `context.WithoutCancel` to keep request-scoped values while dropping the caller's cancellation, then apply your own `context.WithTimeout` so the transaction is still bounded. And make the operation idempotent, because the caller may retry something you actually completed.
- After the context is cancelled, can you rely on the rollback having reached the database by the time Commit returns?No. The rollback is issued by database/sql's background watcher, concurrently with your goroutine, so `Commit` may return `ctx.Err()` while the rollback is still in flight — or `sql.ErrTxDone` once it has landed. Treat any non-nil error from `Commit` as "the transaction did not happen", and if a later step depends on the rows being absent, check rather than assume.
saying these in an interview costs you the question
- Thinks the BeginTx context only covers the Begin call
- Expects Commit to succeed after the context was cancelled
- Passes context.Background() so the transaction can never end itself
- Uses the request context for work that must finish regardless
- Assumes the automatic rollback completes before Commit returns