go 112 lines · 3 tabs

Optimistic Locking in Go With a Version Column on UPDATE

Shared by codesnips Jul 2026
3 tabs
package bank

import "errors"

var ErrVersionConflict = errors.New("optimistic lock: version conflict")
var ErrInsufficientFunds = errors.New("insufficient funds")

type Account struct {
	ID      int64
	Balance int64 // cents
	Version int64
}

func (a *Account) ApplyDebit(amount int64) error {
	if amount <= 0 {
		return errors.New("amount must be positive")
	}
	if a.Balance < amount {
		return ErrInsufficientFunds
	}
	a.Balance -= amount
	return nil
}
3 files · go Explain with highlit

Optimistic locking is a concurrency-control strategy that assumes conflicts are rare: instead of holding a database row lock for the duration of a read-modify-write cycle, each row carries a version counter, and every UPDATE only succeeds if the version in the database still matches the version the caller last read. This snippet shows the full loop across three collaborating files — the domain type and its repository, the store's version-checked update, and a service that retries on conflict.

In account.go, the Account struct exposes a Version int64 field alongside its business data. The important detail is that Version is part of the value the caller loads and later writes back, so it acts as a snapshot token. The ErrVersionConflict sentinel lets higher layers distinguish a genuine concurrency clash from other failures, and ApplyDebit performs the pure in-memory mutation without touching the database.

The heart of the pattern lives in account_store.go. GetByID reads the current row, including its version. Update issues a single atomic statement: UPDATE accounts SET ... version = version + 1 WHERE id = $x AND version = $y. Because the WHERE clause pins the expected version, the write is a compare-and-swap. sql.Result.RowsAffected() reveals the outcome: zero rows means some other transaction already bumped the version between the read and the write, so Update returns ErrVersionConflict. When it succeeds, the in-memory acc.Version is incremented so the same struct stays consistent for any subsequent write.

account_service.go closes the loop with Withdraw, which wraps the read-modify-write in a bounded retry against maxRetries. On each attempt it re-reads a fresh copy with the latest version, applies ApplyDebit, and calls Update; a returned ErrVersionConflict simply triggers another attempt, while any other error aborts immediately.

The trade-off is deliberate: optimistic locking avoids the contention and deadlock risk of SELECT ... FOR UPDATE, which makes it excellent for low-contention, read-heavy workloads. Its weakness is wasted work under high contention, where retries pile up — pessimistic locking or serialized queues fit better there. A common pitfall is forgetting the version = $expected predicate, which silently reintroduces lost updates, so the guard belongs in every write path that touches the row.


Related snips

Share this code

Here's the card — post it anywhere.

Optimistic Locking in Go With a Version Column on UPDATE — share card
Link copied