Data Races in Go: What They Are and Why You Should Care
Data races in Go can lead to unpredictable bugs and hard-to-track crashes. Learn how to detect and fix them using mutexes, channels, and atomic operations.

A data race happens when two or more goroutines access the same memory location at the same time, and at least one of them writes to it — without proper synchronization.
This can lead to unpredictable behavior. Your program might sometimes work just fine, and other times crash or return wrong results. That’s because the order in which goroutines run isn’t guaranteed, so without coordination, one goroutine might read a value while another is in the middle of changing it.
Why Are Data Races a Problem?
Data races aren’t just theoretical—they can cause real, unpredictable problems that are difficult to catch and fix. Here’s a simple example:
func race() {
wait := make(chan struct{})
n := 0
go func() {
n++ // read, increment, write
close(wait)
}()
n++ // conflicting access
<-wait
fmt.Println(n)
}
In this code, both the main goroutine and the anonymous goroutine are updating the same variable n
. Since there’s no coordination between them—no locks, no channels protecting the access—we don’t know which n++
happens first. The output might be 1, 2, or something even weirder depending on how the Go runtime schedules the goroutines.
This is the essence of a data race: multiple goroutines accessing shared memory without synchronization.
So what’s the big deal?
The biggest problem with data races is that they don’t fail consistently. Your code might run fine 100 times and crash on the 101st. That makes them:
-
Hard to reproduce
-
Hard to debug
-
Easy to miss in tests
But the impact can be serious:
-
You might get incorrect results or corrupted state
-
Goroutines might get stuck in deadlocks
-
Your program might even crash if memory is accessed in unsafe ways
If your app "sometimes fails for no reason," a data race could be the hidden culprit.
How to Spot Data Races
The good news? Go makes it easy to spot data races using a built-in tool: the race detector.
Let's try it out with our example:
func main() {
var wg sync.WaitGroup
counter := 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
counter++ // race condition
wg.Done()
}()
}
wg.Wait()
fmt.Println(counter)
}
Each goroutine tries to increment counter. But since there's no synchronization—no mutex, no channel—they can interfere with each other. The result is unpredictable, and chances are the final output won’t be 10.
To catch this, run the program with Go’s race detector:
go run -race main.go
The -race flag enables Go’s runtime race detector. If there’s a data race, you’ll see a warning like this:
This output shows exactly where the race occurred, what variable was involved, and which goroutines were part of the conflict. It’s a powerful way to catch concurrency issues early—ideally before they make it into production.
How to Solve Data Races
Once you’ve spotted a data race, the next step is fixing it. Go gives you three main tools for this: mutexes, atomic operations, and channels. Each has its place, depending on what you're doing and how you want to coordinate access to shared data.
Using a Mutex
A sync.Mutex ensures that only one goroutine can access a piece of code at a time. You lock before accessing the shared variable and unlock after you're done.
var counter int
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
go func() {
mu.Lock()
counter++
mu.Unlock()
wg.Done()
}()
go func() {
mu.Lock()
counter++
mu.Unlock()
wg.Done()
}()
wg.Wait()
fmt.Println("Counter:", counter)
Here, the mutex (mu
) protects access to counter. Only one goroutine can increment it at a time. It’s simple and effective—great for most shared state scenarios.
Using sync/atomic
For basic numeric operations (like counters or flags), the sync/atomic package provides a faster, lock-free alternative.
import "sync/atomic"
var counter int64
var wg sync.WaitGroup
wg.Add(2)
go func() {
atomic.AddInt64(&counter, 1)
wg.Done()
}()
go func() {
atomic.AddInt64(&counter, 1)
wg.Done()
}()
wg.Wait()
fmt.Println("Counter:", counter)
Atomic operations are lightweight and efficient—but they only work for specific use cases, like updating integers or booleans. You can't use them for complex updates or data structures.
Using Channels
Channels are a Go-native way to synchronize access to data. Instead of locking, you "own" the value by passing it through a channel.
counter := make(chan int, 1)
counter <- 0 // initialize
go func() {
val := <-counter
val++
counter <- val
}()
go func() {
val := <-counter
val++
counter <- val
}()
time.Sleep(time.Second) // wait for goroutines
fmt.Println("Counter:", <-counter)
Here, the value lives inside the channel, and only one goroutine can access it at a time. It’s a safe and idiomatic way to coordinate shared state.
Which One Should You Use?
- Use Mutex: when you need simple locking for shared resources.
- Use Atomic: for fast, low-level operations on numbers.
- Use Channels: when goroutines need to communicate, not just protect data.
Data races can quietly break your code in unpredictable ways. The good news? Go gives you simple tools to catch and fix them—like the -race flag, mutexes, channels, and atomic operations.
Just remember: when goroutines share data, coordinate access. Pick the right tool for the job, and you’ll avoid a whole class of sneaky bugs.
"Don’t communicate by sharing memory; share memory by communicating."
— Rob Pike
LiveAPI: Super-Convenient API Docs That Always Stay Up-To-Date
Many internal services lack documentation, or the docs drift from the code. Even with effort, customer-facing API docs can be hard to use. With LiveAPI, connect your Git repository to automatically generate interactive API docs with clear descriptions, references, and "try it" editors.