How to use Go atomic counters, booleans, and atomic.Pointer safely, plus the cases where sync.Mutex is the clearer and more verifiable choice.
· 7 min read

Atomic Operations in Go: When a Mutex Is Still Clearer


The sync/atomic documentation warns that its primitives require great care and recommends channels or the facilities in sync for most synchronization work.

Take the warning seriously. It isn’t a ban. There’s a narrow set of problems where atomics win: one word of independent state that many goroutines read or bump. But the moment correctness depends on two pieces of state agreeing with each other, a mutex is cheaper to reason about and far easier to verify.

The rule I’d start from: atomics protect a variable, a mutex protects an invariant.

Atomic counters with atomic.Int64

Go 1.19 added typed wrappers (atomic.Int32, Int64, Uint32, Uint64, Uintptr, Bool, Pointer[T]), and the docs now steer you toward them over the older atomic.AddInt64(&x, 1) functions. Two reasons to use them: you can’t accidentally do a plain non-atomic read of the field, and the 64-bit types handle alignment for you (more on that below).

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

type Stats struct {
	requests atomic.Int64
	errors   atomic.Int64
}

func (s *Stats) Record(err error) {
	s.requests.Add(1)
	if err != nil {
		s.errors.Add(1)
	}
}

func (s *Stats) Snapshot() (requests, errors int64) {
	return s.requests.Load(), s.errors.Load()
}

func main() {
	var s Stats
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			var err error
			if i%10 == 0 {
				err = fmt.Errorf("boom")
			}
			s.Record(err)
		}(i)
	}

	wg.Wait()
	fmt.Println(s.Snapshot()) // 100 10
}

This is the happy case. Each counter is independent, nobody makes a decision based on both at once, and Snapshot returning a slightly skewed pair doesn’t matter for metrics.

There’s a subtlety worth staring at. Snapshot does two loads. Each load is atomic; the pair is not. If another goroutine calls Record between them, the returned values are not guaranteed to describe the counters at one instant. For a dashboard, fine. For “reject the request if errors/requests > 0.5”, not fine at all.

Also: these types must not be copied after first use. Treat Stats as non-copyable and pass it by pointer. Copy it by value and you get a second set of counters that silently diverges from the first.

Atomic booleans and the size caveat

atomic.Bool is the right way to express a single flag: draining, shutting down, feature enabled.

type Server struct {
	draining atomic.Bool
}

func (s *Server) Handle(w http.ResponseWriter, r *http.Request) {
	if s.draining.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	// serve normally
}

func (s *Server) Drain() {
	// Swap tells you whether you were the one who flipped it.
	if already := s.draining.Swap(true); already {
		return
	}
	// run drain logic exactly once
}

Swap is handy because it hands back the old value, which turns “set the flag” into “set the flag and find out if I won.” Without it you’d reach for CompareAndSwap(false, true).

One documented detail people miss: an atomic.Bool may be larger than a built-in bool. Atomic operations on non-word-sized integers are inefficient or infeasible on many architectures, so the package only supports a few sizes. Don’t build a densely packed struct of 32 atomic.Bool fields and expect 32 bytes.

atomic.Pointer for copy-on-write config

atomic.Pointer[T] is a typed alternative to the unsafe.Pointer functions, and to atomic.Value when the published value is naturally a *T. It buys you a lock-free read path for read-mostly data.

package config

import "sync/atomic"

type Config struct {
	Timeout  int
	Endpoint string
	Features map[string]bool
}

type Store struct {
	cur atomic.Pointer[Config]
}

func NewStore(c *Config) *Store {
	s := &Store{}
	s.cur.Store(c)
	return s
}

// Get is safe to call from any goroutine with no locking.
func (s *Store) Get() *Config {
	return s.cur.Load()
}

// Replace installs a brand new Config. Callers must not mutate
// a Config after passing it here.
func (s *Store) Replace(c *Config) {
	s.cur.Store(c)
}

The contract is what makes this work: the Config is immutable once published. Readers get a pointer to a value nobody will ever write to again. If a reader could mutate Features, you’d be back to needing a lock on the map itself.

This is where the Go memory model earns its keep. The docs put it precisely: if the effect of atomic operation A is observed by atomic operation B, then A “synchronizes before” B. All atomic operations behave as though executed in some sequentially consistent order, the same semantics as C++ sequentially consistent atomics or Java volatile. So the writes that built *c before Store are visible to any goroutine that gets that pointer from Load. You don’t need a fence. You also don’t get to write the config fields after storing the pointer.

atomic.Value can do the same job here but boxes into any, and every Store must use the same concrete type or it panics at runtime. For pointer-shaped state, atomic.Pointer[T] moves that check to compile time.

Compare-and-swap loops

Add handles increments as one atomic operation. When the new value depends on the old value in a way Add, And, or Or can’t express, you need a compare-and-swap loop:

// RecordMax tracks the largest value seen, lock-free. Before first use,
// initialize highWater to math.MinInt64 if values may be negative.
func RecordMax(highWater *atomic.Int64, v int64) {
	for {
		cur := highWater.Load()
		if v <= cur {
			return
		}
		if highWater.CompareAndSwap(cur, v) {
			return
		}
		// Someone else won the race; re-read and try again.
	}
}

Read the state, compute a new state, swap it in only if nothing changed underneath you. If the swap fails, throw away your work and retry. The zero value of atomic.Int64 is only a suitable starting point here when inputs are non-negative; otherwise initialize it to math.MinInt64 first.

Two things to notice. The loop can spin an unbounded number of times under contention: this is lock-free, not wait-free. And this particular case is easy to verify because the state is a single int64 and the transition is monotonic. Stretch the same pattern over a pointer to a mutable structure and you inherit the ABA problem, where the value you compare against gets restored to its old bit pattern while the thing it refers to changed underneath you.

64-bit alignment on 32-bit platforms

The bugs section of the docs is short and it matters. On ARM, 386, and 32-bit MIPS, it’s the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed via the primitive functions (atomic.AddInt64, atomic.LoadInt64, and friends). Get it wrong and the program can panic at runtime on those platforms even though the same code works on an amd64 laptop.

The old workaround was to make the int64 the first field in the struct, because the first word in an allocated struct, array, slice, or global variable is guaranteed to be 64-bit aligned.

The modern fix is simpler. Use atomic.Int64 and atomic.Uint64, which the docs state are automatically aligned. That alone is a good reason to migrate off the function-based API.

When a mutex is clearer

Here’s the case that trips people up. A fixed-window rate limiter:

// BROKEN: two atomics, one invariant.
type BadLimiter struct {
	windowStart atomic.Int64 // unix nanos
	count       atomic.Int64
}

func (l *BadLimiter) Allow(limit int64, window time.Duration) bool {
	now := time.Now().UnixNano()
	start := l.windowStart.Load()
	if now-start > int64(window) {
		l.windowStart.Store(now)
		l.count.Store(0)
	}
	return l.count.Add(1) <= limit
}

Every individual operation is atomic. The function is still wrong. Two goroutines can both decide the window expired and both reset the count, throwing away increments. A third can Add between the windowStart.Store and the count.Store, and its request vanishes. The race detector reports nothing, because there is no data race: this is a logic race, which the race detector cannot catch.

The mutex version is boring and correct:

type Limiter struct {
	mu          sync.Mutex
	windowStart time.Time
	count       int64
}

func (l *Limiter) Allow(limit int64, window time.Duration) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	if now.Sub(l.windowStart) > window {
		l.windowStart = now
		l.count = 0
	}
	l.count++
	return l.count <= limit
}

The critical section is visible in the source. A reviewer reads five lines and checks that every transition keeps windowStart and count consistent. That’s the real cost difference. Not nanoseconds. How much of the concurrency argument lives in your head versus on the page.

Reach for a mutex when:

  • Two or more fields must change together, or be read together consistently.
  • You need check-then-act: read state, decide, then write based on that decision.
  • The state is a map, slice, or any structure you mutate in place. The typed integer and pointer atomics update one atomic value at a time.
  • You need to hold state stable across a call you don’t control.

Reach for atomics when:

  • It’s a counter nobody makes decisions from, or one where Add’s return value is the decision.
  • It’s a single flag.
  • It’s a read-mostly immutable value swapped wholesale via atomic.Pointer[T].

And don’t pick atomics on performance instinct. The current sync.Mutex implementation also uses atomic operations on its uncontended fast path, so the source alone doesn’t tell you which choice is faster in your workload. If contention is specifically on reads, an RWMutex may be the better move, and we covered that tradeoff in Mutexes in Go: when to use sync.Mutex vs sync.RWMutex. Benchmark with go test -bench, and run representative tests with -race to catch accidental data races, before you trade clarity for cycles.

If you do go atomic, write down the contract next to the field. “Immutable once published.” “Only ever increments.” One line of comment is what keeps the next person from bolting a second atomic onto the struct and quietly breaking your invariant.