1. Why it matters to talk about data races
When you run multiple goroutines for the first time, your brain automatically expects the program to behave “almost like usual,” just faster. And sometimes it really looks fine. But concurrent code often does not break right away: it can “work” for years and then crash during a client demo at the exact moment you confidently say “it’s simple here.” A data race is like a crack in the foundation: the house may stand until rain, frost, or just “a bad scheduler day” arrives.
Key idea for today: a data race is not a “rare bug,” it is a violation of the rules of the game. If a race exists, the program becomes unpredictable, and “it sometimes works” only means “you’ve been lucky so far.”
Why “it sometimes works” is especially dangerous
In a sequential program, “first line 1, then line 2” is a pretty honest contract with reality. In a concurrent program, the Go scheduler can “slice” goroutine execution into small chunks and switch between them at any time: due to load, timers, system calls, number of CPU cores, or simply because that’s how it happened.
As a result, you get an unpleasant effect: you run the program 10 times and get 10 different behaviors. And here a beginner’s brain often makes the wrong conclusion: “So it’s just the console output order that varies.” Sometimes, yes. But sometimes the meaning of the computation varies.
Let’s look at a simple flowchart of interleaved actions:
flowchart TD
A[goroutine #1 reads counter] --> B[goroutine #1 increments locally]
C[goroutine #2 reads counter] --> D[goroutine #2 increments locally]
B --> E[goroutine #1 writes counter]
D --> F[goroutine #2 writes counter]
If two read/write streams overlap, one result can overwrite the other. And the most annoying part: sometimes it happens, sometimes it doesn’t — because it depends on the (effectively) random execution schedule.
2. Data race definition in plain words
A data race occurs when two or more goroutines access the same memory location at the same time, and at least one access is a write, and there is no coordination (synchronization). That’s it. A short definition at the “reality check” level.
To make it easier to keep in mind, it helps to imagine a table like this:
| Situation | Do multiple goroutines touch the same variable? | Is there a write? | Is there coordination? | Is it a data race? |
|---|---|---|---|---|
| Only reading a shared value | yes | no | doesn’t matter | no |
| Read + write without rules | yes | yes | no | yes |
| Write + write without rules | yes | yes | no | yes |
| Read/write, but strictly by agreement (synchronization) | yes | yes | yes | no |
Today we are not introducing synchronization as a new tool (no Mutex, atomic, channels). For now, we are learning to recognize the problem and write code so that we don’t create a shared write point “in one pile.”
3. Classic races: from counter++ to map
Why counter++ is not “one operation”
Let’s start with an example that looks so innocent you want to like it and ship it to production.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
counter := 0
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // data race
}()
}
wg.Wait()
fmt.Println("counter =", counter) // can be 1, 2, or 3
}
Why is this a race? Because counter++ is logically three steps: read counter, add 1, write it back. And these steps can interleave across goroutines.
Imagine counter was 0. Two goroutines almost simultaneously read 0. Both increment to 1. Both write 1. In the end, instead of the expected 2, you get 1. And yes, sometimes you will get 2 if you “get lucky” and the executions do not overlap.
WaitGroup does not make data safe
After the previous lecture, it’s easy to catch yourself thinking: “OK, I called wg.Wait(), so everything is under control.” Unfortunately, WaitGroup is a gate at the exit, not lane markings on the road.
It guarantees only one thing: all goroutines have finished. But it does not guarantee they weren’t fighting over the same variable while they were running.
To feel the difference, you can mentally rephrase it: WaitGroup answers the question “when can we continue?” but it does not answer “what happens if they write to the same place at the same time?”
Practically, it means: “I waited for completion” is not the same as “I got a correct result.”
Races in “real” code: structs, fields, and a shared slice
While we were racing on a shared int, everything looked like a toy. But in a real application you almost always share not a single int, but a struct or a slice, and the race becomes less obvious.
Let’s imagine we have a piece of our training app (a hypothetical task manager), and we want to process tasks in parallel and track some stats.
package main
import (
"fmt"
"sync"
)
type Stats struct {
Done int
}
func main() {
var wg sync.WaitGroup
stats := Stats{}
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
stats.Done++ // data race: shared object, shared write
}()
}
wg.Wait()
fmt.Println("done =", stats.Done) // can be 1, 2, or 3
}
Here the race is already “hidden” behind a struct field. But in essence it’s the same counter++, just dressed up more nicely.
A similar trap is a shared slice and append. We already discussed that a slice is “header + underlying array,” and append can change length, capacity, and even reallocate the array. If multiple goroutines call append on the same slice without a protocol, you get a lottery: from “elements disappeared” to “data got corrupted.” So yes, append to a shared slice from different goroutines is almost always a red zone.
A special danger star: concurrent writes to a map
With map in Go there is a special aspect: concurrent writes (and often write+read) without coordination do not just produce “the wrong result,” they can crash the process.
Let’s look at an example:
package main
import (
"sync"
)
func main() {
var wg sync.WaitGroup
m := make(map[int]int)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m[i] = i // unsafe: concurrent write to map
}(i)
}
wg.Wait()
}
Sometimes you will see a panic like fatal error: concurrent map writes. Sometimes you will “get away with it” and notice nothing (which, as we agreed, is not a reason to celebrate). The bad news is that “it didn’t crash” does not mean “it’s correct.”
4. How to avoid races without new primitives
At this stage of the course we don’t yet have tools for synchronizing access to shared memory (we deliberately don’t use them today), but that doesn’t mean we are powerless. The most practical “beginner recipe” sounds like this: each goroutine writes only to its own memory area, and one goroutine merges results after wg.Wait().
This idea resonates with the Go approach “don’t communicate by sharing memory” — it’s often phrased as “share memory by communicating,” meaning that instead of shared writes, it’s better to build communication and clear ownership boundaries.
We are not using channels yet (that will be later), so we will do “communication through preallocated slots.”
Mini case: count task contributions in parallel
Suppose we have tasks and we want to count how many “done tasks” are in the set. Parallelizing just for the sake of parallelism is pointless, but as a learning example it’s perfect.
First, the bad version (race):
package main
import (
"fmt"
"sync"
)
type Task struct {
Title string
Done bool
}
func main() {
tasks := []Task{
{"Read Go book", true},
{"Write code", true},
{"Touch grass", false},
}
var wg sync.WaitGroup
doneCount := 0
for _, t := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
if t.Done {
doneCount++ // data race
}
}(t)
}
wg.Wait()
fmt.Println("doneCount =", doneCount) // can be 1 or 2
}
Now let’s make a race-free version, because each goroutine writes to its own slot marks[i], and we do the summation after waiting:
package main
import (
"fmt"
"sync"
)
type Task struct {
Title string
Done bool
}
func main() {
tasks := []Task{
{"Read Go book", true},
{"Write code", true},
{"Touch grass", false},
}
var wg sync.WaitGroup
marks := make([]int, len(tasks))
for i := 0; i < len(tasks); i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if tasks[i].Done {
marks[i] = 1
}
}(i)
}
wg.Wait()
sum := 0
for i := 0; i < len(marks); i++ {
sum += marks[i]
}
fmt.Println("doneCount =", sum) // doneCount = 2
}
The key magic here is not WaitGroup, but the fact that there is no shared variable that everyone writes to. Each goroutine fills its own slot, meaning it does not compete for the same memory location. And reading marks starts only after wg.Wait(), when all writes are finished.
5. How to think about correctness: ownership and the moment of reading
A “race” can happen even without ++
Beginners often think: “A race is when I do counter++.” That’s a common association, but a race is broader. It’s enough that one goroutine writes while another reads “at the same time,” and you are already in the uncertainty zone.
For example, one goroutine updates a struct field while another prints the whole struct. Or one goroutine changes a slice element while another builds a report string based on that slice. Even if the program “seems” not to crash, you can get partially updated data.
In a everyday analogy it looks like “one person is rewriting a shopping list, and another is taking a photo of the sheet at that moment.” You’ll get a photo, but the question is: what exactly will be on it? “Bread” is already added, “milk” is still being erased, and “cheese” suddenly looks like “chees” — because the pen was mid-stroke.
Two questions that save you from chaos
A very useful habit is to say out loud (or in a comment) two questions.
First: which goroutine is considered the “owner” of this value and is allowed to change it? If the answer is “everyone a little bit,” you are almost guaranteed to be on the path to a race.
Second: when are other goroutines allowed to read this value? If the answer is “well, any time, it’s just a number,” that’s also a warning sign. In concurrent code, “any time” needs to be replaced with “after we waited for completion” or “after the data is published according to an agreement.”
At the level of today’s tools (goroutine + WaitGroup), the simplest agreement sounds like this: write in parallel, read only after wg.Wait(). It’s not a universal law of the universe, but it’s a good starter template that already saves you from many problems.
6. Typical mistakes
Mistake #1: “I used WaitGroup, so there is no race.”
WaitGroup is waiting for completion, not protecting data. It guarantees that the work is finished, but it does not guarantee that the work was performed without simultaneous writes to the same memory. If inside a goroutine you do x++ on a shared variable, the race remains — you just now reliably wait for its consequences.
Mistake #2: “If the program doesn’t crash, everything is fine.”
With races this is especially tricky: they often show up only under load, on different hardware, in a different runtime version, or “when the stars align.” The absence of a crash does not prove correctness. In that sense, “it sometimes works” is exactly a bad sign: you don’t control the behavior.
Mistake #3: “I’m writing to a slice, not one variable, so it’s safe.”
A slice is not “many independent variables,” but a structure with a shared underlying array. If two goroutines write to the same slice without strict index partitioning or without a protocol, you can easily get overlap, especially with append. Even if the indexes are different, the problem can show up if one goroutine changes length/capacity while another assumes the slice structure is stable.
Mistake #4: “A map is like a dictionary: insert and forget.”
A regular map in Go is not designed for concurrent writes without coordination. In the best case you will get incorrect data; in the worst case the process crashes. And again: wg.Wait() won’t save you here, because the race happens before the wait.
Mistake #5: “Come on, I’m only reading a shared object.”
Reading a shared object is safe only when you are sure no one writes to it at the same time. If at least one goroutine writes, the read becomes part of the race. A typical scenario: one goroutine updates stats, another prints a “real-time” report. Without a protocol, it becomes a generator of ghost bugs.
GO TO FULL VERSION