← Files GophersARCHIVED FILE

skills/go-database/references/transactions.md

5.42 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

# Transactions, Isolation, and Locking

A transaction is a fence: writes inside it are atomic, writes outside it are not. Get the isolation level right and you avoid the worst concurrency bugs.

## The Basic Wrapper

```go
func withTx[T any](ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) (T, error)) (T, error) {
    var zero T

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

    out, err := fn(tx)
    if err != nil {
        if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
            return zero, fmt.Errorf("rollback after %v: %w", err, rbErr)
        }
        return zero, err
    }
    if err := tx.Commit(); err != nil {
        return zero, fmt.Errorf("commit: %w", err)
    }
    return out, nil
}
```

Three guarantees:

- Rollback on any error from `fn`.
- Commit only when `fn` returned nil.
- Ignore `sql.ErrTxDone` on rollback (the tx might have ended already).

## Isolation Levels

| Level | Prevents | Allows | Use for |
|---|---|---|---|
| Read Uncommitted | Nothing (rarely supported) | Dirty reads | Don't |
| Read Committed (default) | Dirty reads | Non-repeatable reads, phantoms | Most workloads |
| Repeatable Read | Non-repeatable reads | Phantoms (Postgres: snapshot isolation, also prevents phantoms in many cases) | Reports, exports |
| Serializable | Everything | — but conflicts return `40001` errors | Financial, inventory, anywhere "the answer must be right" |

Default in Postgres is Read Committed. Move to Serializable when correctness beats throughput, and wrap calls in a retry loop for `40001 serialization_failure`.

```go
const maxRetries = 3
for i := 0; i < maxRetries; i++ {
    err = withTx(ctx, db, func(tx *sqlx.Tx) (struct{}, error) {
        return struct{}{}, doMoney(ctx, tx)
    })
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) && pgErr.Code == "40001" {
        time.Sleep(backoff(i))
        continue
    }
    break
}
```

## SELECT FOR UPDATE

When you read a row you intend to update, lock it inside the transaction:

```sql
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
```

This blocks concurrent transactions from reading the same row with `FOR UPDATE` until the lock holder commits or rolls back. Without it, two transfers can each read the same balance, both compute "balance - 100", and both write — losing 100 dollars.

Variants:

- `FOR UPDATE` — exclusive, blocks readers using `FOR UPDATE`.
- `FOR SHARE` — shared, blocks other `FOR UPDATE` but allows other `FOR SHARE`.
- `FOR UPDATE SKIP LOCKED` — skip rows already locked; useful for queue-style workers.
- `FOR UPDATE NOWAIT` — fail immediately if the row is locked.

## Optimistic Locking

Alternative to `FOR UPDATE`: add a `version` column, write `UPDATE ... WHERE version = $1`, and check `RowsAffected`.

```go
res, err := tx.ExecContext(ctx,
    "UPDATE accounts SET balance = $1, version = version + 1 WHERE id = $2 AND version = $3",
    newBalance, id, expectedVersion)
if err != nil { return err }
n, _ := res.RowsAffected()
if n == 0 {
    return ErrConflict // someone else updated concurrently; caller retries
}
```

Optimistic is cheaper than locks when conflicts are rare. Pessimistic (`FOR UPDATE`) is safer when conflicts are common.

## Transaction Scope

```go
// Bad — external service call inside a transaction.
err := withTx(ctx, db, func(tx *sqlx.Tx) (struct{}, error) {
    if _, err := tx.ExecContext(ctx, "UPDATE orders SET status = 'paid' WHERE id = $1", id); err != nil { return struct{}{}, err }
    if err := stripeClient.Capture(ctx, id); err != nil { return struct{}{}, err } // ← holds DB lock during HTTP call
    return struct{}{}, nil
})
```

The transaction holds row locks the entire time the HTTP call runs — seconds, possibly tens of seconds. Concurrent updates queue, p99 latency climbs.

Pattern: do external work first, then a short transaction that records the result.

```go
charge, err := stripeClient.Capture(ctx, id)
if err != nil { return err }
return withTx(ctx, db, func(tx *sqlx.Tx) (struct{}, error) {
    _, err := tx.ExecContext(ctx,
        "UPDATE orders SET status='paid', stripe_charge_id=$1 WHERE id=$2",
        charge.ID, id)
    return struct{}{}, err
})
```

## Read-Only Transactions

For multi-statement reports that must see a consistent snapshot:

```go
tx, _ := db.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
```

The `ReadOnly` flag lets Postgres skip some bookkeeping and is a clear hint to reviewers.

## Distributed Transactions

`database/sql` does not support 2-phase commit across databases. If you need cross-resource consistency:

- Use the outbox pattern: write to a local outbox table in the same transaction as your business write, and let a separate process publish from the outbox.
- Or accept eventual consistency and design for compensating actions (Saga).

Avoid XA / 2PC unless you have specific operational expertise.

## Common Mistakes

| Mistake | Effect |
|---|---|
| Forgetting `tx.Rollback()` on early return | Tx held until connection idles out; pool exhaustion |
| Using `db.QueryContext` inside a tx | Query runs on a different connection — outside the tx |
| Committing in a `defer` | Commits even on panic; lost atomicity |
| Long-running tx waiting on external I/O | Lock contention, p99 explosion |
| Treating Read Committed like Serializable | Race conditions invisible until production load |

SHA-256: 20916e811029cd40d661a77a0839a8c36af09d4764bea4a3f4c8f7cf38b4cbed