Why must statements inside a *sql.Tx go through the Tx rather than the *sql.DB?
answer
- one of them is a pool
- a transaction lives on a session
- the pool hands out a different connection
- the stray write auto-commits and survives rollback
- and it waits on locks the transaction holds
basics
~20 sA *sql.Tx owns one dedicated connection, and the database transaction lives on that connection. A statement issued on the *sql.DB takes a different connection from the pool, so it runs outside the transaction and commits on its own.
solid answer
~40 s`*sql.DB` is a pool, not a connection. `db.BeginTx` pulls one connection out of that pool and binds it to the returned `*sql.Tx`; the server-side transaction exists only on that connection. If code inside the transaction calls `db.ExecContext` instead of `tx.ExecContext`, the pool hands it some *other* connection, so that statement runs in its own implicit transaction: it commits immediately, it is not undone by `tx.Rollback()`, and it cannot see the transaction's uncommitted rows. Worse, it usually blocks — it wants row locks the open transaction is holding, and the transaction cannot proceed because the goroutine is stuck waiting on that statement. The practical rule is to pass `*sql.Tx` into any helper called between `BeginTx` and `Commit`, never the `*sql.DB`.
code
go · 14 linestx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err = tx.ExecContext(ctx, debitSQL, amountMinor, fromID); err != nil {
return err
}
// WRONG: db, not tx -- different connection, separate auto-commit
if _, err = db.ExecContext(ctx, creditSQL, amountMinor, toID); err != nil {
return err
}
return tx.Commit()go deeper
Remember the rule literally: between BeginTx and Commit, every call starts with tx. If a helper needs the database, hand it the *sql.Tx.
Explain why: *sql.DB is a pool, a relational transaction belongs to one session, and only the *sql.Tx guarantees the same connection. Name the three consequences — no atomicity, no visibility of uncommitted rows, and blocking on the transaction's own locks.
Diagnose it from symptoms: a handler that hangs until a context deadline, with rows written that a rollback did not remove. Then propose the structural fix — a narrow interface both handles satisfy, or a closure that only receives the transaction — instead of a review convention.
Own the data-access shape: decide whether repository methods take the transaction handle at all, or whether transaction boundaries live in one service layer. Weigh the flexibility of passing a handle everywhere against the ease of writing this bug in a large team.
## Two handles that look interchangeable and are not `*sql.DB` and `*sql.Tx` expose nearly the same methods — `ExecContext`, `QueryContext`, `QueryRowContext`, `PrepareContext` — which is exactly why this bug is easy to write and hard to see in review. The types mean completely different things: - `*sql.DB` is a **pool** of connections. Every call on it borrows whatever connection is free, runs one statement, and returns the connection. Two consecutive calls on the same `*sql.DB` may well run on two different connections. - `*sql.Tx` is **one in-progress transaction pinned to one connection**. `BeginTx` takes a connection out of the pool, sends `BEGIN` (or the driver's equivalent) on it, and keeps it until `Commit` or `Rollback`. Relational transactions are a property of a *session* — a connection. There is no transaction identifier the client can attach to an arbitrary connection. So the only way a statement joins your transaction is by travelling down the same connection, and the only handle that guarantees that is the `*sql.Tx`. ## What actually happens when you use db inside a transaction Consider a ledger service posting a double-entry pair, where a helper was written before transactions were introduced and still closes over the `*sql.DB`: ```go tx, err := db.BeginTx(ctx, nil) // ... if _, err := tx.ExecContext(ctx, debitSQL, amountMinor, fromID); err != nil { return err } if err := postCredit(ctx, db, toID, amountMinor); err != nil { // takes *sql.DB return err } return tx.Commit() ``` Three things follow, and each of them is a production incident of its own kind. **1. The write is not atomic with the rest.** `postCredit` runs on another connection in its own auto-committed statement. If the later `tx.Commit()` fails or the debit path rolls back, the credit row is still there. You have posted half a double-entry pair — the ledger no longer balances, and no amount of retrying the handler fixes the row that already landed. **2. It cannot read the transaction's work.** Uncommitted rows written through `tx` are invisible on any other connection, whatever the isolation level. A helper that checks "does this row exist yet?" through the `*sql.DB` will say no. **3. It very often hangs.** The debit statement holds row locks. The helper's statement wants those same rows, so the server makes it wait. The goroutine that would eventually call `tx.Commit()` and release those locks is the same goroutine now blocked inside the helper. Nothing breaks the cycle except a timeout — the statement's context deadline, or the server's lock timeout. From the outside it looks like the database has stalled; the cause is entirely in the Go code. Because the transaction's connection is also still checked out, this shape scales badly: every concurrent request that hits it holds two connections and makes progress on neither. ## The fix, and how to make it structural Pass the transaction, not the pool: ```go func postCredit(ctx context.Context, tx *sql.Tx, toID, amountMinor int64) error { _, err := tx.ExecContext(ctx, creditSQL, amountMinor, toID) return err } ``` Teams that keep making this mistake usually fix it with a type, not a rule. Define a narrow interface with the methods both types satisfy and take that everywhere: ```go type execer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) } ``` Both `*sql.DB` and `*sql.Tx` implement it, so a repository method works inside or outside a transaction and the caller decides which. That is a deliberate choice: it makes the call site — not the helper — responsible for atomicity, which is where the decision belongs. ## The neighbouring case: one connection without a transaction If you need several statements on the *same* connection but not inside a transaction — a session-level setting followed by the query that depends on it, for instance — `db.Conn(ctx)` returns a `*sql.Conn` bound to a single connection, which you must `Close` to return it to the pool. `(*sql.Conn).BeginTx` starts a transaction on that specific connection. This is the same pinning idea as `*sql.Tx`, without the transaction semantics. ## Concurrency note A `*sql.DB` is safe to share across goroutines and is designed for it. A `*sql.Tx` is not something you should fan out: it is one connection, statements on it are serialised, and a `Commit` racing an in-flight statement from another goroutine gets you `sql.ErrTxDone` or a half-done transaction. Keep a transaction inside the single goroutine that owns it.
- How do you pin several statements to one connection without opening a transaction?`db.Conn(ctx)` returns a `*sql.Conn` bound to a single connection from the pool; every call on it uses that connection, and `conn.Close()` returns it. Use it when statements depend on session state — a temporary table or a session-level setting. `(*sql.Conn).BeginTx` then starts a transaction on that same connection.
- Is it safe to use one *sql.Tx from several goroutines at once?Technically the calls are serialised on the transaction's single connection, but it is a design error. The order statements reach the server becomes non-deterministic, and a `Commit` racing an in-flight statement ends with `sql.ErrTxDone` or a partially applied transaction. Keep a transaction inside the goroutine that opened it; fan out across separate transactions instead.
- How would you catch this mistake in review rather than in production?Make the wrong handle unavailable: have repository methods take a narrow `execer` interface that both `*sql.DB` and `*sql.Tx` satisfy, and keep the `*sql.DB` out of scope inside the transaction body — for example by running the body as a closure that only receives the `*sql.Tx`. Then no helper can silently reach the pool.
The transaction is a conversation on one phone line. Reaching for the *sql.DB is dialling a second line and expecting the person on the first one to hear you.
saying these in an interview costs you the question
- Believes *sql.DB and *sql.Tx share one connection
- Passes the *sql.DB into helpers called inside a transaction
- Thinks the pool routes calls back to the transaction's connection
- Blames the database when the two statements block each other
- Expects db.QueryContext to see the transaction's uncommitted rows