Fix

"race condition detected" by Go Race Detector

Enable the Go Race Detector by running tests or builds with the -race flag to identify and fix concurrent memory access issues.

Fix: "race condition detected" by Go Race Detector

You run your test suite and every test passes. You add the -race flag to check for concurrency bugs. The terminal explodes with a stack trace starting with WARNING: DATA RACE. Your code works fine in production, or so you assumed. The race detector just caught a bug that could corrupt data at any moment. The program didn't crash. The detector warned you and kept running. This is the most common way Go developers discover hidden concurrency bugs.

What a race condition actually is

A race condition occurs when two goroutines access the same memory location at the same time, and at least one of them is writing. Go does not prevent this by default. The compiler trusts you to manage shared state. The runtime does not lock memory automatically. If you share a variable between goroutines without coordination, the CPU might interleave the reads and writes in a way that produces incorrect results.

Think of a shared whiteboard. Two people try to write on it at the same time. One person erases what the other just wrote. Or they write over each other's text. The final result depends on who moved their hand faster. In a program, the "speed" depends on the scheduler, the CPU cores, and the timing of system calls. The outcome is non-deterministic. The bug might appear once a month or once a year. The race detector makes these invisible bugs visible.

Minimal example: the counter trap

The classic example is a shared counter. Incrementing a variable looks like a single operation, but it is not atomic. The compiler breaks count++ into three steps: read the value, add one, write the result back. If two goroutines interleave these steps, one update gets lost.

package main

import (
	"fmt"
	"sync"
)

// BuggyCounter increments a shared variable without synchronization.
// Running this with -race will trigger a data race warning.
func BuggyCounter() {
	var count int
	var wg sync.WaitGroup

	// Launch goroutines that share the count variable.
	// Each goroutine reads and writes count concurrently.
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			// This line races with other goroutines.
			// The read and write are not atomic.
			count++
		}()
	}

	wg.Wait()
	fmt.Println(count)
}

Run this code with go run -race main.go. The program prints a warning before the final result. The output looks like this:

WARNING: DATA RACE Read at 0x00c000016098 by goroutine 6: Previous write at 0x00c000016098 by goroutine 5:

The detector shows the memory address, the operation type, and the goroutine IDs. It tells you exactly which lines of code conflicted. The program continues running. The final count might be 10, or it might be 8. The result is unpredictable.

How the detector works under the hood

The race detector instruments your binary. It adds checks before every memory access. When you compile with -race, the compiler inserts calls to the race detector runtime around loads and stores. The runtime maintains a shadow memory structure that tracks which goroutine last touched each address.

When a goroutine accesses an address, the detector checks the history. If the history shows a write by a different goroutine without a synchronization event in between, the detector prints the warning. Synchronization events include mutex locks, channel operations, and sync.WaitGroup waits. These events establish a happens-before relationship. The detector uses these relationships to determine if a race exists.

The instrumentation adds overhead. The binary size increases. The runtime slows down by roughly two times. Memory usage can increase five times. Do not run the race detector in production. Use it during development and in your CI pipeline. The cost is worth it. The detector catches bugs that are nearly impossible to find otherwise.

Realistic fix: protecting shared state

The fix depends on your design. The most common approach is to use a mutex. A mutex ensures that only one goroutine can access the shared data at a time. You lock the mutex before reading or writing. You unlock it after. Other goroutines block until the lock is available.

package main

import (
	"fmt"
	"net/http"
	"sync"
)

// RequestCounter tracks HTTP requests using a mutex.
// This struct protects shared state from concurrent access.
type RequestCounter struct {
	mu    sync.Mutex
	count int
}

// Increment safely increases the counter.
// The mutex ensures only one goroutine modifies count at a time.
func (rc *RequestCounter) Increment() {
	rc.mu.Lock()
	defer rc.mu.Unlock()
	rc.count++
}

// HandleRequest processes an HTTP request and updates the counter.
// Each request runs in its own goroutine managed by the server.
func (rc *RequestCounter) HandleRequest(w http.ResponseWriter, r *http.Request) {
	rc.Increment()
	fmt.Fprintf(w, "Total requests: %d", rc.count)
}

The struct holds a sync.Mutex field. The convention is to name the mutex mu. The receiver name matches the type abbreviation. rc for RequestCounter. The Increment method locks the mutex. It uses defer to unlock. This ensures the mutex unlocks even if the function returns early. The critical section is the code between the lock and the unlock. Keep the critical section small. Do not do I/O or heavy computation while holding the lock.

Run the race detector on this code. The warning disappears. The mutex provides the synchronization event the detector needs. The happens-before relationship is established. The detector knows the access is safe.

Pitfalls and runtime panics

The race detector catches many bugs, but it does not catch everything. Some patterns cause runtime panics instead of race warnings. Maps are a common trap. Go maps are not thread-safe. Concurrent writes to a map cause a panic.

fatal error: concurrent map writes

This panic happens at runtime. The race detector will catch the race before the panic if you run with -race. The warning appears first. Fix the race by wrapping map access in a mutex or using a concurrent map implementation.

Slices share an underlying array. Appending to a shared slice can race. The slice header contains a pointer to the array, the length, and the capacity. If two goroutines append to the same slice, they race on the header and the array. The detector will warn about the race. The fix is to use a mutex or to avoid sharing the slice. Pass a copy of the slice to each goroutine.

Atomic operations are another tool. The sync/atomic package provides functions for atomic reads and writes. These operations are faster than mutexes. They are limited to simple types like integers and pointers. You cannot use atomics for complex state. The detector does not warn about atomic operations. They are safe by definition. Use atomics for counters and flags. Use mutexes for everything else.

The detector can miss races. It uses sampling. If a race happens rarely, the detector might not see it. Run your tests multiple times. Increase the test coverage. The detector is not perfect. It is a development tool. It helps you find bugs. It does not guarantee correctness.

Decision: choosing synchronization

Pick the right tool for the job. Concurrency primitives have different trade-offs. Use the simplest thing that works.

Use a sync.Mutex when multiple goroutines need to read and write shared state and you need exclusive access during updates.

Use a sync.RWMutex when reads are frequent and writes are rare, allowing multiple concurrent readers while blocking writers.

Use a channel when you want to transfer ownership of data between goroutines rather than sharing memory.

Use atomic operations when you need a simple counter or flag and want to avoid the overhead of a mutex.

Use local variables and return values when you can avoid sharing state entirely, passing data through function arguments instead.

Run the race detector on every test suite. Trust the warning. Fix the race before merging. The worst goroutine bug is the one that never logs.

Where to go next