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

Mar 23, 2025 - 20:53
 0
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 < 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:

  1. A safe single-threaded program.
  2. A simple race with two goroutines.
  3. A bigger race with 100 goroutines.
  4. How to detect races with -race.
  5. 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!