Go's Race Detector: The Bugs It Misses and Why You Should Care
Concurrency in programming is a double-edged sword. It lets us write fast, responsive applications, but it also opens the door to tricky bugs that can crash your program or silently corrupt data. Go’s built-in race detector is a lifesaver for catching some of these issues, but it’s not a silver bullet. It can’t spot every concurrency bug lurking in your code. In this post, we’ll dig into data races, logical concurrency bugs, and where Go’s race detector shines—or falls short. Expect examples, tables, and straightforward explanations to help you wrap your head around this. Let’s dive in. What’s a Data Race, Anyway? A data race happens when two or more goroutines access the same memory location at the same time, and at least one of them is writing. If there’s no synchronization (like locks or channels), the outcome is unpredictable. Go’s race detector is designed to catch these, and it’s pretty good at it. You enable it with the -race flag when building or running your program (go run -race main.go), and it’ll scream if it detects unsynchronized memory access. Here’s a quick example of a data race it can catch: package main import ( "fmt" "time" ) func main() { count := 0 go func() { count++ // Write }() go func() { fmt.Println(count) // Read }() time.Sleep(time.Second) // Let goroutines finish } Run this with go run -race main.go, and the race detector will flag it. Output: 0 ================== WARNING: DATA RACE Write at 0x00c000014188 by goroutine 7: main.main.func1() /home/shrsv/bin/goconcurrency/uber/uber2.go:11 +0x44 Previous read at 0x00c000014188 by goroutine 8: main.main.func2() /home/shrsv/bin/goconcurrency/uber/uber2.go:14 +0x34 Goroutine 7 (running) created at: main.main() /home/shrsv/bin/goconcurrency/uber/uber2.go:10 +0xa6 Goroutine 8 (finished) created at: main.main() /home/shrsv/bin/goconcurrency/uber/uber2.go:13 +0x110 ================== Found 1 data race(s) exit status 66 Why? Two goroutines are messing with count without any coordination—one’s writing, the other’s reading. The result could be 0, 1, or garbage, depending on timing. Key takeaway: Go’s race detector excels at finding unsynchronized memory access like this. But data races are just one piece of the concurrency puzzle. Let’s explore the bigger picture. Types of Concurrency Bugs: The Usual Suspects Concurrency bugs come in flavors beyond just data races. Some are mechanical (like data races), while others are logical—mistakes in how your program thinks about concurrency. Here’s a breakdown: Bug Type Description Race Detector Catches It? Data Race Unsynchronized access to shared memory Yes Deadlock Goroutines stuck waiting for each other No Livelock Goroutines looping without progress No Starvation A goroutine never gets a chance to run No Logical Race Incorrect ordering or timing assumptions No Go’s race detector is laser-focused on data races. The other bugs? You’re on your own. Let’s unpack each one with examples and see why the race detector can’t help. Deadlocks: When Everyone’s Waiting A deadlock happens when goroutines get stuck waiting for each other, like a traffic jam where no one moves. The race detector doesn’t care about this because it’s not about memory access—it’s about execution flow. Here’s an example: package main import "sync" func main() { var mu sync.Mutex mu.Lock() go func() { mu.Lock() // Waits forever mu.Unlock() }() mu.Unlock() select {} // Keep main alive } The main goroutine locks mu, spawns a new goroutine that tries to lock mu, then unlocks it. But the timing’s off—the second goroutine is stuck waiting for a lock that’s already taken when it starts. Run this with -race, and you’ll get no warnings. It’ll just hang. Why it’s missed: The race detector only looks for conflicting memory access, not whether your program is stuck. Deadlocks are a synchronization problem, not a data race. Fix: Use channels or ensure locks are released in the right order. Tools like Go’s deadlock detector can help, but the race detector won’t. Livelocks and Starvation: The Silent Killers Livelocks and starvation are sneaky. In a livelock, goroutines are busy but not making progress—like two people stepping aside for each other endlessly. In starvation, one goroutine never gets CPU time because others hog it. Here’s a livelock example: package main import ( "fmt" "time" ) func main() { ch := make(chan int, 1) chV2 := make(chan int, 1) go func() { for { select { case ch

Concurrency in programming is a double-edged sword.
It lets us write fast, responsive applications, but it also opens the door to tricky bugs that can crash your program or silently corrupt data.
Go’s built-in race detector is a lifesaver for catching some of these issues, but it’s not a silver bullet.
It can’t spot every concurrency bug lurking in your code. In this post, we’ll dig into data races, logical concurrency bugs, and where Go’s race detector shines—or falls short.
Expect examples, tables, and straightforward explanations to help you wrap your head around this.
Let’s dive in.
What’s a Data Race, Anyway?
A data race happens when two or more goroutines access the same memory location at the same time, and at least one of them is writing.
If there’s no synchronization (like locks or channels), the outcome is unpredictable.
Go’s race detector is designed to catch these, and it’s pretty good at it.
You enable it with the -race
flag when building or running your program (go run -race main.go
), and it’ll scream if it detects unsynchronized memory access.
Here’s a quick example of a data race it can catch:
package main
import (
"fmt"
"time"
)
func main() {
count := 0
go func() {
count++ // Write
}()
go func() {
fmt.Println(count) // Read
}()
time.Sleep(time.Second) // Let goroutines finish
}
Run this with go run -race main.go
, and the race detector will flag it.
Output:
0
==================
WARNING: DATA RACE
Write at 0x00c000014188 by goroutine 7:
main.main.func1()
/home/shrsv/bin/goconcurrency/uber/uber2.go:11 +0x44
Previous read at 0x00c000014188 by goroutine 8:
main.main.func2()
/home/shrsv/bin/goconcurrency/uber/uber2.go:14 +0x34
Goroutine 7 (running) created at:
main.main()
/home/shrsv/bin/goconcurrency/uber/uber2.go:10 +0xa6
Goroutine 8 (finished) created at:
main.main()
/home/shrsv/bin/goconcurrency/uber/uber2.go:13 +0x110
==================
Found 1 data race(s)
exit status 66
Why? Two goroutines are messing with count
without any coordination—one’s writing, the other’s reading.
The result could be 0, 1, or garbage, depending on timing.
Key takeaway: Go’s race detector excels at finding unsynchronized memory access like this.
But data races are just one piece of the concurrency puzzle.
Let’s explore the bigger picture.
Types of Concurrency Bugs: The Usual Suspects
Concurrency bugs come in flavors beyond just data races.
Some are mechanical (like data races), while others are logical—mistakes in how your program thinks about concurrency.
Here’s a breakdown:
Bug Type | Description | Race Detector Catches It? |
---|---|---|
Data Race | Unsynchronized access to shared memory | Yes |
Deadlock | Goroutines stuck waiting for each other | No |
Livelock | Goroutines looping without progress | No |
Starvation | A goroutine never gets a chance to run | No |
Logical Race | Incorrect ordering or timing assumptions | No |
Go’s race detector is laser-focused on data races. The other bugs? You’re on your own. Let’s unpack each one with examples and see why the race detector can’t help.
Deadlocks: When Everyone’s Waiting
A deadlock happens when goroutines get stuck waiting for each other, like a traffic jam where no one moves. The race detector doesn’t care about this because it’s not about memory access—it’s about execution flow.
Here’s an example:
package main
import "sync"
func main() {
var mu sync.Mutex
mu.Lock()
go func() {
mu.Lock() // Waits forever
mu.Unlock()
}()
mu.Unlock()
select {} // Keep main alive
}
The main goroutine locks mu
, spawns a new goroutine that tries to lock mu
, then unlocks it. But the timing’s off—the second goroutine is stuck waiting for a lock that’s already taken when it starts. Run this with -race
, and you’ll get no warnings. It’ll just hang.
Why it’s missed: The race detector only looks for conflicting memory access, not whether your program is stuck. Deadlocks are a synchronization problem, not a data race.
Fix: Use channels or ensure locks are released in the right order. Tools like Go’s deadlock detector can help, but the race detector won’t.
Livelocks and Starvation: The Silent Killers
Livelocks and starvation are sneaky.
In a livelock, goroutines are busy but not making progress—like two people stepping aside for each other endlessly.
In starvation, one goroutine never gets CPU time because others hog it.
Here’s a livelock example:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 1)
chV2 := make(chan int, 1)
go func() {
for {
select {
case ch <- 1:
// Send succeeded
default:
// Channel full, keep trying
}
}
}()
go func() {
for {
select {
case <-ch:
// Receive succeeded
default:
// Channel empty, keep trying
}
}
}()
time.Sleep(time.Second)
}
Both goroutines are active, but if the timing aligns poorly, they could keep missing each other.
The race detector sees no memory conflicts, so it stays quiet.
Starvation might look like this:
package main
import "sync"
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
mu.Lock()
// Simulate work
time.Sleep(time.Millisecond)
mu.Unlock()
}(i)
}
wg.Wait()
}
If one goroutine hogs the lock (say, with longer work), others might never run. Again, no data race, so no warning.
Why they’re missed: These are scheduling or logic issues, not memory access problems. The race detector can’t reason about progress or fairness.
Logical Races: The Bugs That Hide in Plain Sight
Logical races (or "higher-level races") happen when your program assumes goroutines run in a certain order or timing, but they don’t. These are the hardest to catch because they’re not about memory—they’re about meaning.
Here’s an example:
package main
import (
"fmt"
"time"
)
func main() {
ready := false
go func() {
time.Sleep(10 * time.Millisecond)
ready = true
}()
if ready {
fmt.Println("Ready!")
} else {
fmt.Println("Not ready yet.")
}
}
You might expect "Not ready yet" because the goroutine takes 10ms to set ready
.
But Go doesn’t guarantee timing.
On a fast machine, the goroutine might finish first, printing "Ready!" instead.
Run this with -race
, and it’s silent—no shared memory conflict here.
Why it’s missed: The race detector only flags unsynchronized access, not incorrect assumptions about execution order. This is a logic bug, not a data race.
Fix: Use channels or mutexes to enforce order. For example:
ch := make(chan struct{})
go func() {
time.Sleep(10 * time.Millisecond)
ch <- struct{}{}
}()
<-ch
Now the main goroutine waits properly.
What Can You Do About It?
Go’s race detector is awesome for data races, but it’s blind to deadlocks, livelocks, starvation, and logical races. So, how do you catch these?
-
Test Heavily: Use
go test -race
with varied workloads. Stress tests can expose timing issues. - Use Channels: They’re Go’s concurrency superpower—safer than locks for ordering.
-
Timeouts: Add
context.WithTimeout
to avoid hangs. - Static Analysis: Tools like golangci-lint or go-deadlock can spot some issues.
- Read Up: Check out Concurrency in Go by Katherine Cox-Buday for deeper insights.
- Checkout Uber's blog posts about Concurrency Bugs - Post 1 and Post 2
Final thought: The race detector is your first line of defense, but it’s not the whole army. Concurrency bugs are tough—stay paranoid and test like your app’s life depends on it.