The all-or-nothing contract
You are building an e-commerce checkout flow. The code needs to deduct stock from the inventory table and create an order record in the orders table. The inventory update succeeds. The network blips. The order insert fails. The user sees an error, but the stock is already gone. You have lost inventory and customer trust in one request.
Databases solve this with transactions. A transaction groups multiple operations into a single unit of work. Either every operation succeeds and the changes become permanent, or every operation fails and the database reverts to the state before the transaction started. This property is called atomicity. You get all the changes or none of them.
Go does not have a special Transaction keyword. The standard library handles transactions through the database/sql package. You start a transaction, get a Tx object, run queries against that object, and then commit or roll back. The Tx object binds to a specific database connection for the duration of the work. This binding ensures all queries run in the same transactional context.
How transactions work in Go
The database/sql package manages a pool of connections. When you call db.Query or db.Exec, the package grabs a connection from the pool, runs the query, and returns the connection. You never see the connection. The package hides the plumbing.
Transactions change the flow. You must explicitly request a transaction. The package grabs a connection, sends a BEGIN command to the database, and hands you a Tx object. That object holds the connection hostage. Every query you run through tx.Exec or tx.Query uses that same connection. When you call tx.Commit, the package sends COMMIT, the database saves the changes, and the connection goes back to the pool. If you call tx.Rollback, the package sends ROLLBACK, the database discards the changes, and the connection returns to the pool.
The Tx object is your handle. You cannot run transactional queries against the original db object after starting a transaction. The db object still pulls fresh connections from the pool. If you mix db and tx queries, the db queries run outside the transaction. They commit immediately. You break atomicity.
Minimal example
Here is the simplest transaction. It starts a transaction, runs two inserts, and commits. If anything fails, it rolls back.
import (
"context"
"database/sql"
)
// RunTransaction demonstrates a basic transaction flow.
func RunTransaction(ctx context.Context, db *sql.DB) error {
// BeginTx starts the transaction and reserves a connection.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Rollback is idempotent. Calling it after Commit returns sql.ErrTxDone.
// Deferring it ensures the connection is released even if we panic or return early.
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", "Alice")
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES (1)")
if err != nil {
return err
}
// Commit finalizes the transaction.
// If this fails, the defer will call Rollback, which is safe.
return tx.Commit()
}
The defer tx.Rollback() line is the safety net. If ExecContext returns an error, the function returns. The defer runs. Rollback undoes the first insert and releases the connection. If Commit succeeds, the function returns. The defer runs. Rollback sees the transaction is already committed and returns sql.ErrTxDone. The error is ignored by the defer. The connection is released. The pattern works cleanly.
The lifecycle of a Tx object
Understanding the lifecycle prevents resource leaks. A transaction consumes a database connection. Connection pools have limits. If you start a transaction and never commit or roll back, you hold the connection forever. Other requests wait for a free connection. The application stalls.
The Tx object tracks the state. It starts in an active state. Commit or Rollback moves it to a done state. Once done, you cannot run queries. The compiler does not catch this. The runtime returns an error.
Context propagation is mandatory. BeginTx takes a context.Context. This context controls the lifetime of the transaction. If the context is cancelled, the database driver cancels the query and the transaction. The Tx object detects the cancellation and marks the transaction as failed. You must pass the same context to every query inside the transaction. If you pass a different context, the queries might outlive the transaction or vice versa. The driver handles the mismatch, but the behavior is unpredictable.
Isolation levels define how transactions interact with each other. The BeginTx function accepts a *sql.TxOptions struct. The nil value in the example uses the database default. You can set Isolation to sql.LevelReadCommitted, sql.LevelRepeatableRead, or others. The driver translates these to database-specific commands. Not all databases support all levels. The driver returns an error if you request an unsupported level.
// StrictTransaction shows how to set isolation levels.
func StrictTransaction(ctx context.Context, db *sql.DB) error {
// TxOptions configures the transaction.
// ReadCommitted ensures we see changes committed by other transactions.
opts := &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
}
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return err
}
defer tx.Rollback()
// Queries run with the specified isolation level.
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - 100 WHERE id = 1")
if err != nil {
return err
}
return tx.Commit()
}
The TxOptions struct also has a ReadOnly field. Setting ReadOnly to true hints to the database that the transaction will not modify data. Some databases optimize read-only transactions. They might use a different isolation level or skip locking. This is a performance optimization, not a correctness guarantee. The database might still allow writes depending on the driver.
Realistic pattern with context and errors
Real code handles errors explicitly. The if err != nil pattern is verbose. It is also clear. Every failure path is visible. You cannot accidentally swallow an error. The compiler forces you to handle the return value.
In a transaction, error handling has a specific shape. You check the error from BeginTx. You check the error from every query. You return the error immediately. The defer handles the rollback. You do not need to call Rollback manually in the error branches. The defer does it.
// ProcessOrder handles an order within a transaction.
func ProcessOrder(ctx context.Context, db *sql.DB, orderID int64, amount float64) error {
// BeginTx starts the transaction.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// Defer rollback to ensure cleanup.
defer tx.Rollback()
// Update inventory.
_, err = tx.ExecContext(ctx, "UPDATE inventory SET stock = stock - 1 WHERE product_id = $1", orderID)
if err != nil {
return fmt.Errorf("update inventory: %w", err)
}
// Record payment.
_, err = tx.ExecContext(ctx, "INSERT INTO payments (order_id, amount) VALUES ($1, $2)", orderID, amount)
if err != nil {
return fmt.Errorf("record payment: %w", err)
}
// Commit the transaction.
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
The fmt.Errorf with %w wraps the error. This preserves the original error chain. You can unwrap it later to check for specific database errors. The defer tx.Rollback() runs after the function returns. If Commit succeeds, Rollback returns sql.ErrTxDone. The error is discarded. If Commit fails, Rollback attempts to undo changes. The database might have already rolled back due to the commit failure. Rollback handles this gracefully.
Panics inside a transaction require care. If a panic occurs, the defer runs. Rollback executes. The connection is released. The panic propagates up. You should recover panics at the top level of your request handler, not inside the transaction function. Recovering inside the transaction can hide bugs. Let the panic bubble up. The defer ensures the database connection is not leaked.
Pitfalls and compiler traps
Go's type system catches some mistakes. It does not catch all of them.
Using db instead of tx is a common logic error. The db object has methods like ExecContext and QueryContext. The tx object has the same methods. The signatures match. If you have a variable db *sql.DB and you accidentally call db.ExecContext inside a transaction, the code compiles. The query runs outside the transaction. It commits immediately. You break atomicity. The compiler cannot help you here. You must rely on code review and testing.
The compiler does catch type mismatches. If you try to pass a *sql.DB where a *sql.Tx is expected, you get an error.
The compiler rejects this with
cannot use db (variable of type *sql.DB) as *sql.Tx value in argument.
This error appears if you wrap your transaction logic in a helper function that expects a *sql.Tx. You must pass the tx object, not the db object.
Nested transactions are not supported by database/sql. You cannot call BeginTx inside an active transaction. The driver returns an error.
The driver returns
sql: transaction has already been committed or rolled backor a similar error depending on the implementation.
If you need nested transactions, you must use savepoints. The database/sql package does not expose savepoints directly. You must run raw SQL commands like SAVEPOINT and RELEASE SAVEPOINT. This ties your code to a specific database dialect. It reduces portability. Most applications avoid nested transactions. They flatten the logic into a single transaction.
Connection leaks happen when you forget to commit or roll back. If your function returns without calling Commit or Rollback, the Tx object is garbage collected. The connection is not released. The pool shrinks. Eventually, the pool is exhausted. New requests block waiting for a connection. The application hangs. Always use defer tx.Rollback() or ensure every path calls Commit or Rollback.
Context cancellation is a feature, not a bug. If the client cancels the request, the context is cancelled. The transaction should abort. If you ignore the context, the transaction might complete after the client has moved on. You waste resources. You might even cause data inconsistency if the client retries the request. Respect the context. Pass it to every query. Check for cancellation if you have long-running logic.
Decision matrix
Use a transaction when you have multiple writes that must succeed or fail together. Use a transaction when you need to read data and update it based on the read, ensuring no other transaction interferes. Use a transaction when you need isolation from concurrent modifications.
Use separate queries when you only have a single write operation. Transactions add overhead. They reserve a connection and send extra commands. A single Exec or Query is faster and simpler.
Use separate queries when the operations are independent. If the failure of one operation does not require undoing the others, a transaction adds unnecessary complexity.
Use a distributed transaction pattern like Saga or Two-Phase Commit when you need to coordinate changes across multiple databases or services. database/sql transactions only cover a single database connection. They cannot span multiple databases.
Use savepoints when you need nested transaction semantics within a single database. This requires raw SQL and reduces portability. Evaluate if you can refactor the logic to avoid nesting.
Where to go next
Transactions are the foundation of data integrity. Master them early. The patterns are simple. The pitfalls are predictable.
- How to Implement Database Connection Pooling Best Practices in Go
- How to use pgx
- How to Implement Full-Text Search with PostgreSQL in Go
Commit or rollback. Never leave a transaction hanging. Context flows down. Errors flow up. Trust the defer.