Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 029 / 100

Database transactions in Go: what developers should know

Consistent multi-step writes.

Expona Admin8 min read

Database transactions in Go: what developers should know

When you need to write multiple pieces of data to your database and ensure all of them succeed or all of them fail together, transactions are your answer. Database transactions in Go: what developers should know is something every backend engineer encounters when building reliable APIs, yet many teams still handle them inconsistently or incompletely.

We've learned from production incidents that partial writes—where one update succeeds but another fails—can corrupt state in ways that take hours to debug. This article covers the mechanics of transactions in Go, how to structure them safely, and the patterns we use across our platform to keep multi-step writes atomic and reliable.

BeginTx with a context

Starting a transaction in Go means calling BeginTx with a context and transaction options. The context is not optional—it ties the transaction lifetime to a cancellation signal and gives you a hook for timeouts and deadlines.

go
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}

The TxOptions struct lets you specify isolation level and read-only mode. Isolation levels control how concurrent transactions interact:

  • LevelDefault: database default (usually Read Committed)
  • LevelReadUncommitted: allows dirty reads (rarely used)
  • LevelReadCommitted: prevents dirty reads but allows non-repeatable reads
  • LevelRepeatableRead: prevents dirty and non-repeatable reads
  • LevelSerializable: highest isolation, slowest, protects against phantom reads

In most APIs, LevelReadCommitted is sufficient and performs well. Reserve LevelSerializable for sensitive financial or inventory operations where you need absolute guarantees.

Passing the context is critical for production resilience. If the client disconnects or your request deadline passes, the context cancellation propagates to the transaction, preventing orphaned database locks.

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}

defer Rollback — no-op after Commit

The defer-rollback pattern is a Go idiom that works perfectly for transactions. You defer a rollback immediately after beginning a transaction, then explicitly commit at the end. If any error occurs and you return early, the deferred rollback fires. If commit succeeds, the rollback becomes a no-op.

go
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()

// Perform operations
if err := tx.ExecContext(ctx, "UPDATE orders SET status = ? WHERE id = ?", "shipped", orderID).Err; err != nil {
    return fmt.Errorf("update order: %w", err)
}

if err := tx.ExecContext(ctx, "UPDATE inventory SET quantity = quantity - ? WHERE sku = ?", qty, sku).Err; err != nil {
    return fmt.Errorf("update inventory: %w", err)
}

// If we reach here without error, commit
if err := tx.Commit(); err != nil {
    return fmt.Errorf("commit: %w", err)
}

The reason this works: once Commit() succeeds, the transaction is no longer open. Calling Rollback() on a committed transaction returns an error, but by convention many developers ignore it since the transaction is already closed. Some teams wrap it more explicitly:

go
defer func() {
    if err != nil {
        tx.Rollback()
    }
}()

But the simple defer tx.Rollback() is cleaner and more idiomatic. Just make sure you commit before the function returns on the success path.

Pass the tx to repository methods

In layered architectures, your transaction logic lives at the service or handler level, but the actual SQL executes in repository or data access methods. You need a way to pass the transaction down without creating separate method signatures for transactional and non-transactional paths.

The cleanest pattern is to define a small interface that both *sql.DB and *sql.Tx satisfy:

go
type Executor interface {
    ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
    QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

Both *sql.DB and *sql.Tx implement all three methods, so your repository methods can accept an Executor and work in both transactional and non-transactional contexts:

go
type OrderRepo struct{}

func (r *OrderRepo) UpdateStatus(ctx context.Context, ex Executor, orderID string, status string) error {
    _, err := ex.ExecContext(ctx, "UPDATE orders SET status = ? WHERE id = ?", status, orderID)
    return err
}

type InventoryRepo struct{}

func (r *InventoryRepo) Deduct(ctx context.Context, ex Executor, sku string, qty int) error {
    _, err := ex.ExecContext(ctx, "UPDATE inventory SET quantity = quantity - ? WHERE sku = ?", qty, sku)
    return err
}

Now your service layer orchestrates the transaction and passes it to each repository method:

go
func (s *ShipmentService) ProcessShipment(ctx context.Context, orderID string) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback()

    orderRepo := &OrderRepo{}
    inventoryRepo := &InventoryRepo{}

    if err := orderRepo.UpdateStatus(ctx, tx, orderID, "shipped"); err != nil {
        return fmt.Errorf("update order: %w", err)
    }

    if err := inventoryRepo.Deduct(ctx, tx, "sku123", 5); err != nil {
        return fmt.Errorf("deduct inventory: %w", err)
    }

    return tx.Commit().Err
}

This pattern decouples your repositories from transaction awareness while keeping the service layer clean and testable. You can inject a mock Executor in tests without touching repository code.

Keep transactions short

Long-running transactions lock rows, tables, or ranges depending on your database and isolation level. These locks block other queries, causing cascading slowdowns. Best practice is to keep transactions as short as possible: begin late, commit early.

Let's say you're processing a payment in an order flow:

go
func (s *PaymentService) ChargeAndShip(ctx context.Context, orderID string) error {
    // Fetch order data <em>outside</em> transaction
    order, err := s.orderRepo.GetByID(ctx, s.db, orderID)
    if err != nil {
        return fmt.Errorf("get order: %w", err)
    }

    // Call external payment API <em>outside</em> transaction
    receipt, err := s.paymentGateway.Charge(ctx, order.CustomerID, order.Total)
    if err != nil {
        return fmt.Errorf("charge: %w", err)
    }

    // Only the database writes go in the transaction
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback()

    if err := s.orderRepo.UpdateStatus(ctx, tx, orderID, "paid"); err != nil {
        return fmt.Errorf("update order: %w", err)
    }

    if err := s.paymentRepo.RecordTransaction(ctx, tx, orderID, receipt.ID); err != nil {
        return fmt.Errorf("record transaction: %w", err)
    }

    return tx.Commit().Err
}

Notice: we fetch the order and charge the external API before touching the transaction. Only the two database writes happen inside BeginTx. This keeps the lock duration minimal.

A common anti-pattern is wrapping the entire API call in a transaction. If the payment gateway times out or flakes, you're holding a transaction lock for seconds, starving other requests. Move I/O outside, keep the atomic database operations inside.

Common mistakes

Forgetting to check the Commit error: A commit can fail if there's a constraint violation or other issue. Always check the error:

go
if err := tx.Commit(); err != nil {
    return fmt.Errorf("commit: %w", err)
}

Using different contexts for different operations: Create one context, reuse it throughout the transaction so that a single deadline governs the entire operation:

go
// Wrong
op1, _ := tx.ExecContext(context.Background(), query1)
op2, _ := tx.ExecContext(context.Background(), query2)

// Right
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
op1, _ := tx.ExecContext(ctx, query1)
op2, _ := tx.ExecContext(ctx, query2)

Nesting BeginTx inside a transaction: SQL doesn't support nested transactions; attempting a BeginTx while already in a transaction will error. Accept an Executor parameter and let callers decide whether to wrap in a transaction.

Ignoring isolation levels: The database default is often fine, but if you're doing repeatable reads or have concurrent updates to the same rows, test different isolation levels in staging. Higher isolation is slower but safer.

Key takeaways

  • Use BeginTx(ctx, opts) to start a transaction with a context for timeouts and cancellation.
  • Defer rollback immediately after beginning; it becomes a no-op after commit.
  • Define an Executor interface so repository methods work with both *sql.DB and *sql.Tx.
  • Keep transactions short: move I/O and expensive operations outside the transaction boundary.
  • Always check the Commit() error; failures can happen even after all individual queries succeed.
  • Choose isolation level intentionally; most workloads are fine with Read Committed.
  • Pass the same context to all operations within a transaction so one deadline governs all of them.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles