Race Conditions in Go: A Simple Tutorial
Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy. Recently, I've been dealing with a few race conditions in my go program with many goroutines going off all at the same time. In practical contexts - race conditions usually happen when multiple goroutines access and modify shared data at the same time, leading to unpredictable results. This tutorial will take you from the basics to more complex examples of race conditions in Go. We'll use simple, runnable code examples to show what goes wrong and how to spot these issues. Note that I am mainly focused on practical typical scenarios in which race conditions emerge - and not an exhaustive coverage of possibilities. What Is a Race Condition? A race condition occurs when the outcome of a program depends on the timing of goroutines. If two or more goroutines try to read and write the same variable without control, you get buggy behavior. Let’s start with a basic example where a race condition doesn’t yet happen: package main import "fmt" func main() { counter := 0 counter++ fmt.Println("Counter:", counter) } Output: Counter: 1 Here, there’s no race condition because we’re only using one goroutine (the main function). The variable counter is incremented safely. But when we add goroutines, things can go wrong. Introducing Goroutines and a Simple Race Condition Now, let’s add goroutines to increment a shared variable. This is where race conditions start to appear. package main import ( "fmt" "time" ) func increment(counter *int) { *counter++ } func main() { counter := 0 go increment(&counter) // First goroutine go increment(&counter) // Second goroutine time.Sleep(time.Second) // Wait for goroutines to finish fmt.Println("Counter:", counter) } Output (may vary): Counter: 1 or Counter: 2 What’s happening? We expect counter to be 2 (two increments), but sometimes it’s 1. Why? The goroutines are racing to update counter. One might overwrite the other’s change because there’s no coordination. This is a race condition. Run this multiple times—you’ll see different results. That’s the unpredictability of a race! Section 3: Seeing the Race Condition in Action Let’s scale it up with more goroutines to make the problem clearer: package main import ( "fmt" "time" ) func increment(counter *int) { *counter++ } func main() { counter := 0 for i := 0; i

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy.
Recently, I've been dealing with a few race conditions in my go program with many goroutines going off all at the same time.
In practical contexts - race conditions usually happen when multiple goroutines access and modify shared data at the same time, leading to unpredictable results.
This tutorial will take you from the basics to more complex examples of race conditions in Go.
We'll use simple, runnable code examples to show what goes wrong and how to spot these issues.
Note that I am mainly focused on practical typical scenarios in which race conditions emerge - and not an exhaustive coverage of possibilities.
What Is a Race Condition?
A race condition occurs when the outcome of a program depends on the timing of goroutines.
If two or more goroutines try to read and write the same variable without control, you get buggy behavior.
Let’s start with a basic example where a race condition doesn’t yet happen:
package main
import "fmt"
func main() {
counter := 0
counter++
fmt.Println("Counter:", counter)
}
Output:
Counter: 1
Here, there’s no race condition because we’re only using one goroutine (the main
function).
The variable counter
is incremented safely. But when we add goroutines, things can go wrong.
Introducing Goroutines and a Simple Race Condition
Now, let’s add goroutines to increment a shared variable. This is where race conditions start to appear.
package main
import (
"fmt"
"time"
)
func increment(counter *int) {
*counter++
}
func main() {
counter := 0
go increment(&counter) // First goroutine
go increment(&counter) // Second goroutine
time.Sleep(time.Second) // Wait for goroutines to finish
fmt.Println("Counter:", counter)
}
Output (may vary):
Counter: 1
or Counter: 2
What’s happening?
We expect counter
to be 2 (two increments), but sometimes it’s 1.
Why? The goroutines are racing to update counter
.
One might overwrite the other’s change because there’s no coordination.
This is a race condition.
Run this multiple times—you’ll see different results. That’s the unpredictability of a race!
Section 3: Seeing the Race Condition in Action
Let’s scale it up with more goroutines to make the problem clearer:
package main
import (
"fmt"
"time"
)
func increment(counter *int) {
*counter++
}
func main() {
counter := 0
for i := 0; i < 100; i++ {
go increment(&counter)
}
time.Sleep(time.Second)
fmt.Println("Counter:", counter)
}
Output (may vary):
Counter: 97
or Counter: 88
or something else below 100
We launched 100 goroutines to increment counter
, so it should be 100, right? But it’s almost always less. Each goroutine reads and writes counter
at the same time, and some updates get lost. For example:
- Goroutine 1 reads
counter = 5
, increments it to 6. - Goroutine 2 reads
counter = 5
(before Goroutine 1 writes), increments it to 6. - Both write back 6, losing one increment.
This overlapping is the heart of a race condition.
Detecting Race Conditions with Go’s Race Detector
Go has a built-in tool to spot race conditions. Run the program with the -race
flag:
go run -race yourfile.go
Let’s use it on our last example:
package main
import (
"fmt"
"time"
)
func increment(counter *int) {
*counter++
}
func main() {
counter := 0
for i := 0; i < 100; i++ {
go increment(&counter)
}
time.Sleep(time.Second)
fmt.Println("Counter:", counter)
}
When you run go run -race main.go
, you’ll see a warning like:
WARNING: DATA RACE
Read at 0x00c0000a4010 by goroutine 6:
main.increment()
/path/to/main.go:9 +0x44
Previous write at 0x00c0000a4010 by goroutine 5:
main.increment()
/path/to/main.go:9 +0x54
This tells us two goroutines are clashing over counter
. The race detector doesn’t fix the problem—it just helps you find it.
Fixing Race Conditions (A Preview)
To fix race conditions, you often need synchronization. Go offers tools like sync.Mutex
. Here’s how we can fix the last example:
package main
import (
"fmt"
"sync"
"time"
)
func increment(counter *int, mu *sync.Mutex) {
mu.Lock() // Lock before changing counter
for i := 0; i < 10; i++ {
*counter++
}
mu.Unlock() // Unlock after
}
func main() {
counter := 0
var mu sync.Mutex
for i := 0; i < 5; i++ {
go increment(&counter, &mu)
}
time.Sleep(time.Second)
fmt.Println("Counter:", counter)
}
Output:
Counter: 50
Now it’s always 50!
The Mutex
(short for mutual exclusion) ensures only one goroutine updates counter
at a time.
No more race condition.
We’ll dive deeper into fixes in another tutorial, but this shows the basic idea.
Wrapping Up
Race conditions in Go happen when goroutines fight over shared data without rules. You’ve seen:
- A safe single-threaded program.
- A simple race with two goroutines.
- A bigger race with 100 goroutines.
- How to detect races with
-race
. - A sneak peek at fixing it with
Mutex
.
Try running these examples yourself.
If you are not able to practically see race conditions - just increase goroutines/loops - the problems will start showing up soon enough!
Change the numbers, add more goroutines, and use the race detector.
The more you experiment, the better you’ll understand how races sneak into your code!