DEV Community

Cover image for Go Race Detector Output: 3 Stack Shapes That Mean Different Things
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go Race Detector Output: 3 Stack Shapes That Mean Different Things


The first time you see WARNING: DATA RACE in your terminal
you scroll. Wall of stack frames, two columns of "Read at" and
"Previous write at", goroutine IDs that mean nothing yet, a
creation stack at the bottom. The output is dense on purpose.
The detector is showing you everything it knows so you can
reconstruct the interleaving that produced the race.

Race reports come in a small number of shapes. Once you have
read a few, they collapse into patterns. This post is about
three: a read/write race on a shared map, a
closure-over-loop-variable race, and a data race carried by an
interface fat-pointer. Each looks similar in the report and
needs a different fix.

The detector itself is the Go port of
ThreadSanitizer
.
It instruments every memory access at compile time when you
build with -race, then watches for two goroutines that touch
the same address without a happens-before edge between them. It
is observational: it only reports interleavings that actually
happened during the run. That detail matters for shapes 2 and 3
below, where the race fires only under specific scheduling.

Every report has the same skeleton:

==================
WARNING: DATA RACE
Read at 0x00c000094180 by goroutine 7:
  main.read(...)
      /home/you/svc/main.go:42 +0x44

Previous write at 0x00c000094180 by goroutine 6:
  main.write(...)
      /home/you/svc/main.go:31 +0x6c

Goroutine 7 (running) created at:
  main.main()
      /home/you/svc/main.go:55 +0xb8

Goroutine 6 (finished) created at:
  main.main()
      /home/you/svc/main.go:54 +0x9c
==================
Enter fullscreen mode Exit fullscreen mode

Four blocks, in order: read site, previous write site, then the
creation stacks for both goroutines. The address
(0x00c000094180) is the memory location the race is on. Same
address in both blocks means it is the same variable. Different
addresses on the same line of source mean two distinct races on
neighbours, typically a struct with two unsynchronised fields.

Shape 1: read/write on a shared map

The most common shape. A map is created in main or some
constructor, then handed to multiple goroutines that read and
write it without coordination. The Go runtime catches map races
specifically: there is a separate concurrent map writes panic
that fires before the race detector runs. With -race on, the
detector reports the race in its own format first.

Here is a small program that reproduces the shape:

package main

import (
    "fmt"
    "sync"
)

type cache struct {
    data map[string]int
}

func (c *cache) get(k string) int {
    return c.data[k]
}

func (c *cache) set(k string, v int) {
    c.data[k] = v
}

func main() {
    c := &cache{data: map[string]int{}}
    var wg sync.WaitGroup
    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            c.set(fmt.Sprintf("k%d", i), i)
        }(i)
    }
    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            _ = c.get(fmt.Sprintf("k%d", i))
        }(i)
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

go run -race main.go produces output that points at
c.data[k] = v on one side and return c.data[k] on the
other. Both stack frames live in tiny methods on the same
struct. The "Goroutine created at" lines point at two different
loops in main.

What to read: the read site and the write site sit inside the
same data structure (the cache.data map). The goroutine
creation stacks are two unrelated callers that happened to both
get a pointer to that map. That is the signature of a shared
mutable map without synchronisation.

The fix depends on the read/write ratio. Three options, ordered
by overhead:

// Option A: sync.RWMutex when reads dominate writes
type cache struct {
    mu   sync.RWMutex
    data map[string]int
}

func (c *cache) get(k string) int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[k]
}

func (c *cache) set(k string, v int) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[k] = v
}
Enter fullscreen mode Exit fullscreen mode
// Option B: sync.Map when keys are stable and writes are rare
type cache struct {
    data sync.Map
}

func (c *cache) get(k string) (int, bool) {
    v, ok := c.data.Load(k)
    if !ok {
        return 0, false
    }
    return v.(int), true
}
Enter fullscreen mode Exit fullscreen mode
// Option C: atomic.Pointer[map] for read-heavy, swap-whole map
type cache struct {
    data atomic.Pointer[map[string]int]
}
Enter fullscreen mode Exit fullscreen mode

sync.RWMutex is the default. sync.Map pays off when the key
set is mostly stable and reads dominate by a wide margin.
Benchmark; do not assume. atomic.Pointer works when the whole
map is rebuilt rarely (config reload, snapshot rotation) and
reads must never block.

If the report points inside runtime.mapaccess1 or
runtime.mapassign, you have a map race. That is the runtime
function name in the stack. Same shape every time.

Shape 2: closure-over-loop-variable

Pre-Go 1.22, this was one of the most-asked Go questions on
Stack Overflow. The fix landed in the language: for i := range xs
now scopes i per iteration, not per loop. But three classes
of code still produce this race:

  1. Code on Go versions older than 1.22.
  2. Modules with go directive set to 1.21 or below in go.mod, even when compiled with a newer toolchain — the compiler honours the directive's version for loop scoping.
  3. Loops that capture a variable declared outside the loop, where the per-iteration scoping does not apply.

The classic shape:

func processAll(jobs []Job) {
    var wg sync.WaitGroup
    for _, job := range jobs {
        wg.Add(1)
        go func() {
            defer wg.Done()
            process(job)
        }()
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

On Go 1.21 with -race, this reports a race on job. The read
in the goroutine and the write done by range (advancing the
iterator) both touch the same address, and they have no
happens-before edge.

What to read: the address in the report is the same on both
read and write. The read stack is inside the goroutine. The
write stack is inside the loop's range step in processAll.
Goroutine creation stack is the same processAll function.
That triangle is the closure-capture fingerprint: same function
spawns the goroutine, same function writes the loop variable.

There are three valid fixes. Pick by intent:

// Fix A: capture explicitly (works on every Go version)
for _, job := range jobs {
    job := job
    wg.Add(1)
    go func() {
        defer wg.Done()
        process(job)
    }()
}
Enter fullscreen mode Exit fullscreen mode
// Fix B: pass as parameter (also version-independent)
for _, job := range jobs {
    wg.Add(1)
    go func(j Job) {
        defer wg.Done()
        process(j)
    }(job)
}
Enter fullscreen mode Exit fullscreen mode
// Fix C: bump go.mod to 1.22 and rely on per-iteration scope
// go.mod:
//   go 1.22
// then the original loop is correct as-is
Enter fullscreen mode Exit fullscreen mode

Fix C is the cleanest for new code and worth the go.mod bump
on its own. Read the Go 1.22 release
notes
for the edge cases
(loops that intentionally shared the variable between
iterations now silently behave differently).

If you upgrade go.mod and the same race comes back, the
captured variable was declared outside the loop, not by range
itself:

func processAll(jobs []Job) {
    var current Job
    for _, current = range jobs {
        go func() { process(current) }()
    }
}
Enter fullscreen mode Exit fullscreen mode

current is a function-scope variable, not loop-scope. The
1.22 fix does not apply. Move it inside the loop, or pass it as
a parameter.

Shape 3: interface fat-pointer race

This one is the hardest to read. A Go interface value is a
two-word header: a type pointer and a data pointer. Storing an
interface from one goroutine while another reads it is two
loads and two stores that the runtime does not protect — the
write might land half-completed (new type, old data, or the
reverse), and the reader can pull a torn pair.

Practical setup: a worker pool whose workers consult a hot
config object. Operators reload the config in a background
goroutine. The config is held as an interface field on a
struct, swapped non-atomically.

type Provider interface {
    Limit() int
}

type liveConfig struct{ limit int }

func (c *liveConfig) Limit() int { return c.limit }

type Service struct {
    cfg Provider
}

func (s *Service) Reload(c Provider) {
    s.cfg = c
}

func (s *Service) Handle() int {
    return s.cfg.Limit()
}
Enter fullscreen mode Exit fullscreen mode

Two goroutines, one calling Reload, the other calling
Handle. With -race, the report points at s.cfg = c (the
write) and s.cfg.Limit() (the read). The address is the same
in both blocks. There is no map function in the stack. There is
no obvious closure. It looks like a single-field assignment.
That is exactly the trap.

What to read: write site is a plain = assignment to an
interface-typed field, read site is a method call through that
field. Same address, two stack frames in unrelated goroutines.
That pair is the interface-write race fingerprint.

The fix is atomic.Pointer[T] (Go 1.19+) on the typed pointer,
not on the interface:

type Service struct {
    cfg atomic.Pointer[liveConfig]
}

func (s *Service) Reload(c *liveConfig) {
    s.cfg.Store(c)
}

func (s *Service) Handle() int {
    return s.cfg.Load().Limit()
}
Enter fullscreen mode Exit fullscreen mode

The signature changed: the field is no longer an interface. If
you genuinely need the interface (multiple implementations), wrap
it in a small struct so you can store a single pointer:

type cfgHolder struct{ p Provider }

type Service struct {
    cfg atomic.Pointer[cfgHolder]
}

func (s *Service) Reload(p Provider) {
    s.cfg.Store(&cfgHolder{p: p})
}

func (s *Service) Handle() int {
    return s.cfg.Load().p.Limit()
}
Enter fullscreen mode Exit fullscreen mode

The pointer is one word, so its store and load are atomic on
all 64-bit platforms Go supports. The interface header is two
words and is not.

A sync.RWMutex would also fix it, and is the right call when
the config object itself is being mutated rather than swapped
whole. atomic.Pointer only works for the swap-the-pointer
pattern.

What not to do: silencing the report

Two anti-patterns come up enough to name. Both make the report
go away without fixing the race.

//go:nocheckptr — this disables the unsafe-pointer
checker, not the race detector. It is occasionally cargo-culted
in as a "make the warnings stop" annotation. It does not stop
race reports. If you see it added next to a race fix, that is a
sign the fix is wrong.

Adding a time.Sleep in the test — sometimes a flaky
-race failure goes away when a sleep is added "to let the
goroutine finish". The race did not go away. The interleaving
that exposes it is just less likely, and production traffic
will hit it again.

If you actually want to mute a specific known-safe access, the
closest knob is runtime.RaceDisable() / runtime.RaceEnable(),
accessed via //go:linkname since they live in the internal
runtime. They exist for runtime authors and Cgo glue, not
application code. If you reach for them in a regular service,
you are almost certainly hiding a real race.

How to read a fresh report

A short reading order that works on most reports:

  1. Same address, both blocks? If yes, it is one variable shared between two goroutines. If no, it is two distinct races on neighbours (usually a struct with two unsynchronised fields).
  2. Look at the function names in the read/write stacks. If they are inside runtime.mapaccess1 or runtime.mapassign, it is shape 1. If they are inside the same function that spawns the goroutines, it is shape 2. If they are a plain field assignment vs a method call through that field, it is shape 3.
  3. Look at the goroutine creation stacks. Two different creation sites means two unrelated callers found the same data — usually a constructor handed out a pointer it should not have. Same creation site means a loop or pool spawned workers that share state.
  4. Decide synchronisation by ratio. Reads ≫ writes → RWMutex or atomic.Pointer. Stable keys, rare writes → sync.Map. Whole-object swap → atomic.Pointer. Many short critical sections → Mutex.

The detector is honest about what it found. The problem is
almost never that the report is wrong. The interleaving fired
in test once. Now you have to figure out why before the next
deploy.


If this was useful

The Complete Guide to Go Programming
walks through the runtime layer this post lives on top of —
the scheduler, the memory model's happens-before edges, and the
ThreadSanitizer-derived algorithm the race detector implements.
Reading reports gets faster once you know what mapaccess1,
runtime.gopark, and the goroutine-id numbering are doing
behind the stack frames. It is part of Thinking in Go, the
2-book series — paired with Hexagonal Architecture in Go
for the design-layer counterpart that keeps shared state out of
the places it should not be in the first place.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)