skip to content

Why does a *sql.Stmt prepared on *sql.DB not run inside your *sql.Tx, and what does tx.Stmt do?

level: middleimportance: should knowfreq 34%

answer

  1. a transaction is one connection
  2. the statement never learns about it
  3. outside the transaction, so not rolled back
  4. it can wait on locks the tx holds
  5. tx.Stmt rebinds it to that connection

basics

~20 s

A *sql.Tx owns one connection for its whole life, while a statement prepared on the *sql.DB runs on whatever connection the pool hands it. So its work happens outside the transaction. tx.Stmt returns a copy bound to the transaction's connection.

solid answer

~40 s

`db.BeginTx` takes one connection out of the pool and keeps it until `Commit` or `Rollback`; everything that is in the transaction is what runs on that connection. A `*sql.Stmt` prepared on the `*sql.DB` does not know the transaction exists — it borrows its own connection per execution, so its writes are a separate session: not covered by the rollback, unable to see the transaction's uncommitted rows, and quite capable of blocking on row locks the transaction itself is holding. `tx.Stmt(stmt)` or `tx.StmtContext(ctx, stmt)` fixes this by returning a transaction-specific `*sql.Stmt` that operates on the transaction's connection and is closed automatically when the transaction commits or rolls back. If a statement is only ever used inside transactions, prepare it with `tx.PrepareContext` instead and skip the conversion.

code

go · 11 lines
go
tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

txStmt := tx.StmtContext(ctx, stmt) // runs on the transaction's connection
if _, err := txStmt.ExecContext(ctx, idx, id); err != nil {
	return err
}
return tx.Commit()

go deeper

for a junior

Remember that a transaction lives on one connection, and that anything you run through the *sql.DB while it is open is a separate session that the rollback will not undo.

for a middle

Explain the rebinding: tx.Stmt and tx.StmtContext give you a statement on the transaction's connection, reusing an existing one there when they can, and closing themselves when the transaction ends.

for a senior

Describe the production failure it causes — a request wedged on a row lock its own transaction holds — and how you recognise it from a stuck goroutine paired with a blocked query on the server.

for a principal

Set the convention that removes the trap: functions that take part in a transaction accept the transaction handle explicitly, so no reachable *sql.DB statement is available to use by accident inside one.

## A transaction is a connection In `database/sql`, `db.BeginTx(ctx, opts)` checks out exactly one connection from the pool and holds it for the whole life of the `*sql.Tx`. Every `tx.ExecContext`, `tx.QueryContext` and `tx.QueryRowContext` runs on that connection. The transaction is not a Go-level concept the package layers over your calls — it is a property of that one server session. This is the fact everything else follows from: **being inside `BeginTx` and `Commit` in your source code does not make a call part of the transaction. Running on the transaction's connection does.** ## What a DB-level statement does instead A `*sql.Stmt` from `db.PrepareContext` belongs to the `*sql.DB`. When you execute it, it asks the pool for a connection — any free one — prepares itself there if it has not already, and runs. If a transaction is open at that moment on another connection, the statement neither knows nor cares. The consequences are all bad and all quiet: - **The work is not in the transaction.** `tx.Rollback()` will not undo it. You get a partial write that survives a failure the rest of your code treated as atomic. - **It cannot see uncommitted rows.** A statement reading rows the transaction has just written will not find them, so logic that depends on read-your-writes silently takes the wrong branch. - **It can block on the transaction's own locks.** The transaction holds row locks on its connection. The statement, on a second connection, waits for them. They cannot be released until the transaction commits, and the transaction cannot proceed because the goroutine is blocked inside the statement's execution. That is a self-deadlock: one request, two connections, waiting on itself until a context deadline or the server's lock timeout cuts it loose. Under load, several requests doing this hold connections while stuck, and the stall spreads. None of this produces a compile error or a runtime warning. The types line up perfectly. ## tx.Stmt and tx.StmtContext `tx.Stmt(stmt)` and its context-taking form `tx.StmtContext(ctx, stmt)` take a statement you already prepared on the `*sql.DB` and return a transaction-specific `*sql.Stmt` — one that operates within the transaction, on its connection. Two details are worth knowing: **It may or may not cost a prepare.** If the parent handle already has a server-side statement on the connection the transaction happens to be holding, that one can be reused. If not, the statement is prepared on that connection, which is an extra round trip taken inside the transaction — while it holds its locks. **It closes itself.** The returned statement is closed when the transaction is committed or rolled back. You do not need to track it, and you must not keep using it afterwards. The parent handle is untouched and stays usable for the life of the `*sql.DB`. ```go tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() txStmt := tx.StmtContext(ctx, stmt) if _, err := txStmt.ExecContext(ctx, idx, id); err != nil { return err } return tx.Commit() ``` ## When to use which - **Statement used both inside and outside transactions:** keep one long-lived handle on the `*sql.DB` and convert it with `tx.StmtContext` where a transaction is in play. - **Statement used only inside transactions:** `tx.PrepareContext` prepares directly on the transaction's connection. It is bound there and dies with the transaction — no conversion, no parent handle, no chance of using the wrong one. The trade is that you prepare once per transaction, so it suits transactions that run the statement several times. - **Statement run once inside a transaction:** just call `tx.ExecContext(ctx, sql, args...)`. Preparing a statement to use it a single time inside one transaction adds a round trip and a lifetime for nothing. ## The design lesson The reason this bug is common is that a repository method with a `*sql.DB` on its struct compiles and works fine — until someone calls it from inside a transaction. The durable fix is structural: make functions that participate in a transaction take the transaction explicitly, so there is no reachable `*sql.DB` handle for them to use by accident, and the mistake becomes impossible rather than merely discouraged.

  • When is the statement returned by tx.StmtContext closed?
    When the transaction ends. It operates within the transaction and is closed on `Commit` or `Rollback`, so you do not track it separately — and you must not use it afterwards. The parent `*sql.Stmt` it was derived from is unaffected and remains usable for the life of the `*sql.DB`.
  • Does tx.StmtContext always cost an extra prepare?
    No. If the parent handle already has a server-side statement on the connection the transaction is holding, that one can be reused. Otherwise the statement is prepared on that connection, which is an extra round trip taken while the transaction holds its locks — a reason to prefer `tx.PrepareContext` for statements only ever used inside transactions.
  • How does this mistake deadlock a request rather than merely return stale data?
    The transaction holds row locks on its own connection. The DB-level statement runs on a second connection and waits for those locks, which cannot be released until the transaction commits — and the transaction cannot proceed because the goroutine is blocked inside the statement. It unsticks only when a context deadline or the server's lock timeout fires.

saying these in an interview costs you the question

  • Thinks any call made between BeginTx and Commit is in the transaction
  • Says tx.Stmt just tags the existing handle with no server work
  • Expects Rollback to undo work done through a DB-level statement
  • Keeps using the tx-specific statement after the commit
  • Assumes database/sql routes calls onto the transaction automatically