JavaRush /Courses /Go SELF /go test -race — what it catches and what it doesn’t

go test -race — what it catches and what it doesn’t

Go SELF
Level 71, Lesson 0
Available

1. Why you need go test -race

If an ordinary bug is like a hole in a pipe (water leaks all the time—stable and predictable), then a data race is like a cat that sometimes walks through a wall. You can’t really “complain” about it properly: “but look, it happened a minute ago!” That’s exactly why Go has a special tool—the race detector—that helps make the invisible visible.

When we have concurrent code (or code that can become concurrent: HTTP handlers, a worker pool, parallel tests), we want to answer a simple question: “Do I have simultaneous write/read to the same memory without synchronization?” That is the main point of go test -race.

It’s important not to mix up terms. There is a broader concept: a race condition, when the result depends on the order of events. It can happen even without simultaneous access to memory. A classic example is “whoever gets there first wins” in concurrent logic, like “take the first response and ignore the rest.” And a data race is specifically about memory: when multiple goroutines touch the same memory location without an agreement.

Go’s -race helps catch exactly data races. And that alone can save you days of your life, a couple of nerve cells, and one gray hair (or twenty).

2. What a data race is

To really feel what “counts as a race,” it helps to state the formal rule in plain words. Here comes a bit of “legal language,” but I’ll translate it into human terms.

A data race occurs when:

  1. there are two (or more) goroutines,
  2. they access the same memory area (a variable, a struct field, a map element, a slice element, etc.),
  3. at least one access is a write,
  4. and between these accesses there is no synchronization, i.e., no “agreement” that makes the order safe (mutexes, channels, atomics, and other recognized mechanisms).

Two nuances that often surprise beginners.

The first nuance is that “the same memory area” is not only “a global variable n.” It can be a field inside a struct allocated on the heap, a shared map, a shared slice, a shared buffer, a shared bytes.Buffer, and so on. In other words, the “shared object” matters more than “a global variable.”

The second nuance is that a race can happen even when the result “seems correct.” For example, you expect two increments to produce 2, and sometimes you do get 2. But the fact that the write was unsynchronized is already a defect. -race catches exactly a defect in the access model, not only a “wrong value.”

3. How to run go test -race

When you run tests with the -race flag, Go builds and runs the test binary in a special mode: it adds instrumentation that tries to notice unsynchronized memory accesses at runtime.

This usually looks like this (as a reference, not as a “mandatory command”):

go test -race ./...

You can also run it narrowly—one package:

go test -race ./internal/task

Or a single test (handy when you’ve already found a suspicious spot):

go test -race -run TestInMemoryStore_Add_Race ./internal/task

There’s an important practical consequence: -race makes the program noticeably slower and more memory-hungry. That’s normal. You don’t need it “all the time in prod”; you need it as a diagnostic spotlight: turn it on, look around, turn it off. Like a flashlight in a basement you don’t want to move into.

One more note for your mental model. The race detector is not new; it has been part of the Go ecosystem for many years. For example, the Go 1.3 release notes mentioned a faster race detector among the improvements. This matters psychologically: the tool is mature and practical, not an “experimental toy.”

4. An example race in an in-memory task store

Imagine we’re building a learning project: a “task manager” app—task storage, task creation, task listing. Suppose we started with a simple in-memory store based on map[int]Task and auto-incrementing nextID. While everything is single-threaded, it’s great. But as soon as parallel scenarios appear (for example, multiple workers import tasks, or tests run concurrent operations), the “simple” design becomes dangerous.

Here is a very simplified version of the store without protection:

package task

type InMemoryStore struct {
	nextID int
	items  map[int]string
}

func NewInMemoryStore() *InMemoryStore {
	return &InMemoryStore{nextID: 1, items: make(map[int]string)}
}

func (s *InMemoryStore) Add(title string) int {
	id := s.nextID
	s.nextID++
	s.items[id] = title
	return id
}

From a business-logic point of view, everything sounds honest: take nextID, increment it, put the task into the map. But from the memory-model perspective, there are immediately two shared points: nextID and items.

Now a test that performs two concurrent writes. It’s tiny, almost funny—but that’s exactly the teaching moment: races often start with “what could possibly go wrong here?”

package task

import (
	"sync"
	"testing"
)

func TestInMemoryStore_Add_Race(t *testing.T) {
	s := NewInMemoryStore()

	var wg sync.WaitGroup
	wg.Add(2)

	go func() { defer wg.Done(); _ = s.Add("a") }()
	go func() { defer wg.Done(); _ = s.Add("b") }()

	wg.Wait()
}

If you run this test without -race, it will most likely be green. And this is where your brain makes a dangerous conclusion: “so it’s fine.” -race exists to explain to your brain: “no, it’s not fine—you just got lucky.”

5. How to read a race detector report

A race detector report often scares beginners with its volume: some goroutines, some stacks, some memory addresses. The good news: you don’t need to understand everything. You need to understand two things: where one side of the conflict happened, and where the other side happened.

A typical report (I’m intentionally showing a simplified “shape” rather than copying full output):

WARNING: DATA RACE
Write at ... by goroutine 7:
  ... file.go:NN

Previous read at ... by goroutine 8:
  ... file.go:MM

The meaning is this.

The first part shows one access (for example, a write), and the call stack leads you to the line where the write actually happened. The second part shows another conflicting access (for example, a read—or also a write) and also provides a stack and a line number.

Here is a simple way to view it as a diagram:

goroutine A: writes to X  ----\
                              => conflict on X (no synchronization)
goroutine B: reads X     ----/

What to look for first.

Look at your files and your lines. Memory addresses and internal runtime calls are almost never your starting point. Usually it’s enough to find two lines in your code and ask: “Why can these two pieces of code run at the same time and touch the same thing?”

One more important practical point: a race report does not always mean that “both places are equally at fault.” Often one access is a normal “work with state” operation, and the other is “we accidentally touched it in parallel.” The fix usually consists of introducing a protocol: either protect the critical section with a mutex, or redesign interaction via channels, or remove shared state.

6. What -race catches—and what it doesn’t

With -race, it’s easy to start believing in magic: “I enabled it, ran it—so everything is correct.” That belief is dangerous. The race detector is powerful, but it has limits: it catches what actually happened during the run, and it catches specifically data races.

It’s useful to keep a table like this in mind:

Situation Will -race catch it? Why
Two goroutines read and write the same variable without a mutex/channel Yes This is the classic data race: shared memory + a write without synchronization
Concurrent access to a map without protection Yes (and it often crashes even without -race) map is not thread-safe, and the tool clearly sees unsynchronized accesses
A logic bug of “whoever got there first is right,” but without shared memory No This is a race condition as a logical dependency on order, but not a data race
Deadlock (everyone waits for each other) No This is not a data race; it’s a locking/channel protocol issue
A race that exists in theory, but “didn’t happen” in this run It may not catch it The tool is dynamic: no conflicting interleaving—no signal
A bug caused by an incorrect order of actions within a single goroutine No This is just an ordinary bug, without concurrency

Notice the third row: a race condition is broader than a data race. We’ve already seen this when talking about concurrent patterns and timeouts: sometimes correctness depends on event ordering. But -race is not a detector of “bad scenarios”; it’s a detector of “bad memory access.”

Another boundary to remember: the absence of a -race report is not proof that there are no races. It is proof that there were no races in this particular run on this machine, with this goroutine scheduling. It’s a very strong signal of “this spot is definitely bad,” but not an absolute guarantee of “everything is definitely good everywhere.”

7. How to increase the chance of catching a race

The problem with races is that they sometimes hide. You enable -race, run the test—silence. And there’s a temptation to say: “well, that’s it, we’re done.” A better approach is: “let’s give the race a chance to show itself.”

The simplest amplifier is to repeat the concurrent operation many times. In tests, that’s just a loop. For example, we can do not 2 concurrent Add calls, but 1000, distributed across several goroutines. Balance matters: the test should remain fast, but “noisy enough” so the conflict becomes likely.

Here is an example of a “strengthened” test (note: it’s still short, and it has no time.Sleep):

package task

import (
	"strconv"
	"sync"
	"testing"
)

func TestInMemoryStore_Add_RaceMany(t *testing.T) {
	s := NewInMemoryStore()

	var wg sync.WaitGroup
	for g := 0; g < 4; g++ {
		wg.Add(1)
		go func(g int) {
			defer wg.Done()
			for i := 0; i < 200; i++ {
				_ = s.Add("t" + strconv.Itoa(g*1000+i))
			}
		}(g)
	}
	wg.Wait()
}

Why do we avoid time.Sleep as an “amplifier”? Because Sleep makes tests flaky: on one machine “it made it,” on another it “didn’t make it.” Our goal is for the test to be repeatable in meaning: we run a concurrent load and verify that under that load there are no races. If you add Sleep, you start testing timing, not your code.

If you need to run tests multiple times, people often use the -count=1 flag to disable test result caching and actually execute them on each run. This is not magic and not a “race fix”; it’s just a way to be sure you’re really running the test rather than reading an old result.

8. How to fix the race: a mutex and a re-check

Now the best part: the fix. In our InMemoryStore we have a shared nextID and a shared map. The state change logic must become a critical section.

Let’s add a sync.Mutex and protect the “take id → increment → write to map” section:

package task

import "sync"

type InMemoryStore struct {
	mu     sync.Mutex
	nextID int
	items  map[int]string
}

func (s *InMemoryStore) Add(title string) int {
	s.mu.Lock()
	defer s.mu.Unlock()

	id := s.nextID
	s.nextID++
	s.items[id] = title
	return id
}

This change looks small, but conceptually it’s huge: we introduce an agreement. Now even if Add is called from 10 goroutines at the same time, access to nextID and items becomes serialized.

Then you run the same tests with -race, and the tool stops complaining. This is exactly the loop you should build into your habits: “wrote concurrent code → made a test with concurrent load → ran with -race.”

And an important note: a mutex is not the only solution. Sometimes it’s better to design the architecture without shared state—for example, send operations through a channel to a single goroutine that owns the state. But at our current level, a mutex is a straightforward and honest tool: easy to read, predictable in behavior.

9. Common mistakes when working with go test -race

Mistake No. 1: thinking -race proves the absence of races.
It’s very tempting to live in a world where one command gives a mathematical guarantee. But -race is a dynamic tool: it shows what happened during execution. If a race depends on a rare goroutine schedule, it may not show up in a single run. So silence from -race is a good sign, but not a “safety certificate.”

Mistake No. 2: “fixing” races with time.Sleep.
When you see that “sometimes” two threads interfere with each other, your hand reaches for a pause: “let one finish first.” This almost always makes things worse. You’re not introducing a protocol—you’re guessing timing. On a different CPU, under different load, with a different scheduler, it will fall apart again.

Mistake No. 3: protecting only the write while leaving the read unprotected.
Beginners often put a mutex “where the write happens” and think that’s enough. But if another goroutine reads the same field without the mutex, it’s still a data race. The protocol must be symmetric: either you agree that access to the field is always under a lock, or you agree the field is immutable after initialization, or you use another safe mechanism.

Mistake No. 4: writing parallel tests that share global state.
Even if the production code is perfect, tests can create races themselves: a shared global store, a shared map with fixtures, a shared counter, a shared temporary file without isolation. Then -race complains, and you fix the wrong place. The discipline is simple: each test creates its own state, especially if you use t.Parallel().

Mistake No. 5: ignoring the race report and “fixing at random.”
The report almost always gives you two points: where one access is, and where the other is. If you start wrapping everything in mutexes “at random,” you risk ending up with either complicated code, or deadlocks, or simply losing track of what you’re protecting. It’s better to follow the report: find the two lines, identify the shared object, and choose one clear protocol.

1
Task
Go SELF, level 71, lesson 0
Locked
Broken scoreboard
Broken scoreboard
1
Task
Go SELF, level 71, lesson 0
Locked
Issuing passes
Issuing passes
1
Task
Go SELF, level 71, lesson 0
Locked
Dictionary snapshot
Dictionary snapshot
1
Task
Go SELF, level 71, lesson 0
Locked
Squares Without a Data Race
Squares Without a Data Race
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION