Transactions, Prepared Statements & sql.Tx
Transactions group multiple statements into one atomic unit.
Search across all documentation pages
Transactions group multiple statements into one atomic unit.
Prepared statements reuse parsed SQL for repeated parameters.
sql.Tx ties both together: begin with context, execute inside the boundary, then commit or roll back.
BeginTx(ctx, opts) starts a transaction with optional isolation level.
Commit persists changes; Rollback discards them - call one path after errors.
PrepareContext creates a Stmt; Tx.Stmt reuses it inside a transaction.
Defer Rollback after BeginTx so error paths do not leak open transactions.
Quick-reference recipe card - copy-paste ready.
func Transfer(ctx context.Context, db *sql.DB, from, to int64, amount int) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, from,
); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to,
); err != nil {
return err
}
return tx.Commit()
}When to reach for this:
package main
import (
"context"
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
func main() {
db, _ := sql.Open("sqlite3", ":memory:")
defer db.Close()
ctx := context.Background()
_, _ = db.ExecContext(ctx, `CREATE TABLE ledger (
id INTEGER PRIMARY KEY,
balance INTEGER NOT NULL
)`)
_, _ = db.ExecContext(ctx, `INSERT INTO ledger (id, balance) VALUES (1, 100), (2, 0)`)
stmt, err := db.PrepareContext(ctx, `UPDATE ledger SET balance = balance + ? WHERE id = ?`)
if err != nil {
panic(err)
}
defer stmt.Close()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
panic(err)
}
defer tx.Rollback()
txStmt := tx.Stmt(stmt)
if _, err := txStmt.ExecContext(ctx, -25, 1); err != nil {
panic(err)
}
if _, err := txStmt.ExecContext(ctx, 25, 2); err != nil {
panic(err)
}
fmt.Println(tx.Commit())
}What this demonstrates:
PrepareContext once, Tx.Stmt to bind the stmt to the active transaction.defer tx.Rollback() covers panic and error exits; Commit succeeds after work completes.ctx flows through begin, exec, and commit for cancellation.database/sql API.BeginTx checks out a dedicated connection until commit or rollback.tx run on that same connection, preserving isolation guarantees.Stmt objects may cache server-side prepared plans depending on driver settings.Commit, the transaction object is invalid; start a new BeginTx for more work.| Level | Constant | Typical use |
|---|---|---|
| Default | nil opts | Driver default (often read committed) |
| Read committed | LevelReadCommitted | Most OLTP services |
| Repeatable read | LevelRepeatableRead | Consistent reads in one tx |
| Serializable | LevelSerializable | Strongest, may retry on conflict |
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback() // no-op after successful Commit
}()Rollback after Commit returns sql.ErrTxDone - safe to ignore in defer.sql.ErrTxDone only if you double-commit by mistake.Some teams use raw SAVEPOINT SQL inside a tx for partial rollback.
Prefer fewer, shorter transactions over deep savepoint nesting unless the database team documents patterns.
Rollback on error path - Connection stays in aborted state and leaks from pool. Fix: defer tx.Rollback() immediately after BeginTx.db.Exec inside an open tx for related writes - Statements run on different connections and break atomicity. Fix: only tx.ExecContext for transactional work.stmt.Exec while a tx is open bypasses the transaction connection. Fix: tx.Stmt(stmt) or prepare on tx directly.Stmt while tx still running - Undefined behavior on some drivers. Fix: close stmts after tx completes or use short-lived prepare per request.| Alternative | Use When | Don't Use When |
|---|---|---|
Single-statement Exec | One atomic UPDATE | Multi-table invariants |
| Saga / outbox pattern | Cross-service consistency | Single-database ACID is enough |
| ORM transaction helper | Team on GORM/sqlx wrappers | You need explicit isolation docs |
| Advisory locks | Application-level mutex in DB | Simple row updates suffice |
SELECT FOR UPDATE | Pessimistic row lock | High contention without short tx |
Yes immediately after BeginTx.
Successful Commit makes deferred Rollback a no-op.
Expose WithTx(ctx, fn) helpers or accept *sql.Tx on internal methods.
Keep *sql.DB as the default constructor dependency.
BeginTx and ExecContext honor ctx; rollback on cancel to release the connection.
Do not leave txs open after handler exit.
When the same SQL runs many times per second and profiling shows parse overhead.
Many drivers auto-cache; benchmark first.
Some databases optimize read-only txs; use TxOptions{ReadOnly: true} when the driver supports it for reporting queries.
Force the second statement to fail and assert the first change is not visible after Rollback.
Use real SQLite or Postgres in integration tests.
Share one tx only when the driver and isolation level allow concurrent use - usually one tx per goroutine is safer.
Serialize writes on a single tx connection.
Some DDL cannot run inside txs on certain engines; migration tools document per-database behavior.
Online services should not run migrations per request.
sql.ErrTxDone - indicates logic bug; fix call paths rather than swallowing silently.
An open tx holds one pool connection until commit or rollback.
Keep txs short to avoid pool starvation under load.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 18, 2026