How to Avoid Race Conditions in Go

Prevent Go race conditions by using sync.Mutex or sync.RWMutex to lock shared variables during read or write operations.

When timing breaks your logic

You write a simple counter. It works perfectly in a single goroutine. You wrap the logic in a loop, spawn ten goroutines, and suddenly the final count is wrong. Sometimes it is too low. Sometimes the program crashes with a panic. The code looks fine. The arithmetic is correct. The problem is timing. Two goroutines read the same value, both add one, and both write back the same result. You just hit a race condition.

What a race condition actually is

A race condition happens when multiple execution paths touch the same piece of memory at the same time, and at least one of them changes it. Go does not guarantee the order in which goroutines run. The runtime scheduler pauses and resumes them based on CPU availability, system calls, and internal timers. That scheduling is intentionally unpredictable. It keeps your program responsive, but it also means you cannot rely on execution order to protect shared state.

Think of a shared whiteboard in a busy office. If two people write on it at once, their pens overlap. If one person erases while the other is writing, half the message disappears. The whiteboard itself does not care who is using it. You need a rule that says only one person can hold the marker at a time. In Go, that rule is a mutex. The word comes from mutual exclusion. It forces concurrent operations to line up and take turns.

The simplest lock

Here is the most straightforward way to protect a shared variable. You declare a mutex alongside the data, lock it before touching the variable, and unlock it immediately after.

package main

import (
    "fmt"
    "sync"
)

var (
    mu    sync.Mutex
    count int
)

// Increment safely adds one to the shared counter.
func Increment() {
    mu.Lock()          // blocks other goroutines until this one acquires the lock
    count++            // safe to modify because no one else can read or write right now
    mu.Unlock()        // releases the lock so the next waiting goroutine can proceed
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            Increment()
        }()
    }
    wg.Wait()
    fmt.Println(count) // prints 10 every time
}

A mutex does not make your code faster. It makes it correct.

How the lock works under the hood

The sync.Mutex type holds an internal state that tracks whether the lock is held. When a goroutine calls Lock(), it checks that state. If the lock is free, the goroutine grabs it and continues. If another goroutine already holds it, the current one parks itself and waits. The scheduler moves on to other work. When the first goroutine calls Unlock(), it marks the lock as free and wakes up one waiting goroutine.

The actual variable count lives in the binary's data segment. The mutex does not protect the variable by magic. It forces the goroutines to serialize their access. Only one thread of execution can pass through the locked region at a time. The CPU still runs them concurrently, but the critical section becomes strictly sequential. The compiler cannot optimize away the lock. It has to emit actual instructions that check and modify the mutex's internal state, often using low-level atomic operations provided by the operating system.

The Go community almost always pairs mu.Lock() with defer mu.Unlock(). The defer statement guarantees the lock releases even if a panic occurs or an early return slips in. It keeps the unlock call visually close to the lock call and removes a whole class of accidental deadlocks. You will see this pattern in virtually every standard library package that manages shared state.

Read-heavy workloads

Real applications rarely just increment a single integer. They read shared state constantly and write to it occasionally. A read-heavy cache or configuration store benefits from sync.RWMutex. It allows multiple readers to proceed simultaneously while still blocking them when a writer needs exclusive access.

package main

import (
    "fmt"
    "sync"
)

// Config holds application settings that change rarely but are read often.
type Config struct {
    mu    sync.RWMutex
    value string
}

// Get returns the current setting without blocking other readers.
func (c *Config) Get() string {
    c.mu.RLock()       // allows other goroutines to also hold the read lock
    v := c.value       // snapshot the value while the read lock is active
    c.mu.RUnlock()     // release immediately so writers are not starved
    return v
}

// Set updates the setting and blocks all readers and writers until finished.
func (c *Config) Set(v string) {
    c.mu.Lock()        // exclusive access required to prevent torn writes
    c.value = v        // safe to overwrite because no readers are currently inside
    c.mu.Unlock()      // release the exclusive lock
}

func main() {
    cfg := &Config{value: "initial"}
    fmt.Println(cfg.Get()) // prints initial
    cfg.Set("updated")
    fmt.Println(cfg.Get()) // prints updated
}

The read lock is a shared lock. Multiple goroutines can call RLock() at the same time. They all read the memory concurrently. The write lock is exclusive. A single Lock() call blocks until all active readers have called RUnlock(). Once the writer gets the lock, new readers park and wait. This pattern prevents lock contention on hot read paths. It also avoids the classic problem where a single mutex forces every read to wait behind the previous read, even though reading an integer or string is perfectly safe to do in parallel.

Read locks are cheap. Write locks are expensive. Design your data flow around that trade-off.

The race detector and silent bugs

Race conditions are silent until they break something. Go ships with a built-in race detector that instruments your binary at compile time. Run your program with go run -race main.go or go test -race ./.... The detector watches memory access patterns and prints a stack trace the moment two goroutines touch the same address without synchronization.

The output looks like a detailed report showing which goroutine read the variable, which one wrote it, and where the lock was supposed to be. It lists the exact line numbers and goroutine IDs. If you ignore it, you will eventually see corrupted data or a panic like fatal error: concurrent map writes. Go deliberately panics on concurrent map access because silent corruption is worse than a crash. The standard library maps are not thread-safe by design. They assume you handle synchronization yourself.

Deadlocks are the other side of the coin. If you call Lock() twice on the same mutex without unlocking in between, the program hangs forever. The runtime will eventually print fatal error: all goroutines are asleep - deadlock!. The fix is usually to reduce lock scope. Lock only around the exact lines that touch shared state. Do not lock before a network call or a database query. Hold the lock for microseconds, not milliseconds. Long-held locks turn your concurrent program into a sequential bottleneck.

The race detector is not a linter. It is a runtime safety net. Run it on every test suite.

Choosing the right primitive

Concurrency tools are not interchangeable. Picking the wrong one adds latency, increases memory usage, or introduces subtle bugs. Match the primitive to the access pattern.

Use a sync.Mutex when you need exclusive access to a small piece of shared state and reads and writes happen with similar frequency. Use a sync.RWMutex when your workload is heavily skewed toward reads and you want to avoid serializing concurrent lookups. Use a channel when the shared state is a queue or a pipeline and you want to transfer ownership of data between goroutines instead of protecting a single variable. Use the sync/atomic package when you only need to increment, decrement, or swap a single integer or pointer and you want to avoid the overhead of a full mutex. Use plain sequential code when you do not actually need concurrency. The simplest thing that works is usually the right thing.

Locks are boundaries. Draw them tight, hold them short, and never share a marker.

Where to go next