Common Goroutine Mistakes and How to Avoid Them

Avoid goroutine mistakes like data races and leaks by using channels, mutexes, and WaitGroups to manage concurrency safely.

Common Goroutine Mistakes and How to Avoid Them

You write a function that processes a list of URLs. You wrap the loop body in go func() { ... }(). It feels fast. You run it on ten URLs. It works. You run it on ten thousand. The memory usage climbs. The CPU spikes. Then the process vanishes. Or it sits there, consuming resources, doing nothing. You didn't get a panic. You got a leak. Or you got a race condition that corrupts your data only when the scheduler decides to interleave instructions in a specific way.

Goroutines are the engine of Go concurrency, but they require discipline. Spawning them is easy. Coordinating them is the hard part. The mistakes aren't usually about syntax. They are about lifecycle management, shared state, and assuming the runtime will save you from bad design.

Goroutines are workers, not magic

Think of goroutines as workers in a warehouse. Hiring a worker takes almost no effort. The runtime allocates a small stack and schedules the function to run. The real cost comes from coordination. If two workers reach for the same box at the same time, they collide. If a worker waits for a signal that never comes, they stand idle forever, still on the payroll. You need a system to track who is working, how they share resources, and when they go home.

Goroutines run concurrently. They might run in parallel on multiple cores, or they might time-slice on a single core. You never control the schedule. Your code must be correct regardless of the interleaving. That means protecting shared data and ensuring every goroutine has a clear path to exit.

Minimal coordination pattern

The safest way to start is with a sync.WaitGroup to track completion and a sync.Mutex to protect shared state. This pattern prevents the main goroutine from exiting too early and stops data races on counters.

package main

import (
	"fmt"
	"sync"
)

// ProcessItem increments a shared counter safely using a mutex.
func ProcessItem(wg *sync.WaitGroup, mu *sync.Mutex, counter *int) {
	defer wg.Done() // Decrement the wait group counter when the function returns.

	mu.Lock()   // Acquire the lock before accessing shared state.
	*counter++  // Modify the shared variable.
	mu.Unlock() // Release the lock so other goroutines can proceed.
}

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	counter := 0

	// Track two concurrent tasks.
	wg.Add(2)

	// Start two goroutines that run concurrently.
	go ProcessItem(&wg, &mu, &counter)
	go ProcessItem(&wg, &mu, &counter)

	// Block until all goroutines call Done.
	wg.Wait()

	fmt.Println("Result:", counter)
}

The defer wg.Done() call ensures the counter decrements even if the function panics. The mutex serializes access to counter. Without the mutex, two goroutines could read the same value, increment it, and write it back, losing one update. That is a data race.

How the wait group and mutex interact

The WaitGroup tracks the number of active goroutines. You call wg.Add(n) before starting the goroutines. Each goroutine calls wg.Done() when it finishes. wg.Wait() blocks the caller until the counter reaches zero.

The order matters. If you call wg.Add inside the goroutine, the main goroutine might reach wg.Wait() before the child adds itself. The wait group counter stays at zero, Wait returns immediately, and the main goroutine exits. The program ends, killing the child goroutine silently. Always call Add before go.

The mutex protects data. It does not coordinate lifecycle. You still need the WaitGroup to know when work is done. Mutexes are for correctness. WaitGroups are for synchronization.

Go code follows strict formatting conventions. Run gofmt on this file and the output is identical to what you wrote. The community relies on gofmt to eliminate style debates. Most editors run it on save. Trust the tool.

Realistic example with context and channels

Real applications need cancellation and data flow. This example fetches profiles concurrently, respects a deadline, and collects results via a channel. It also demonstrates the correct way to handle loop variables in goroutines.

package main

import (
	"context"
	"fmt"
	"sync"
)

// FetchProfile simulates an I/O bound operation that respects context cancellation.
func FetchProfile(ctx context.Context, id string, results chan<- string) {
	// Check if context is already cancelled before starting work.
	select {
	case <-ctx.Done():
		return
	default:
	}

	// Simulate work that can be interrupted.
	select {
	case results <- fmt.Sprintf("Profile %s loaded", id):
		// Success: result sent to channel.
	case <-ctx.Done():
		// Context cancelled: abort and clean up.
		fmt.Println("Cancelled fetch for", id)
	}
}

// RunBatch processes a list of IDs concurrently with a cancellation deadline.
func RunBatch(ctx context.Context, ids []string) []string {
	var wg sync.WaitGroup
	results := make(chan string, len(ids))

	for _, id := range ids {
		wg.Add(1)
		// Pass id as an argument to capture the value, not the variable.
		go func(id string) {
			defer wg.Done()
			FetchProfile(ctx, id, results)
		}(id)
	}

	// Close the results channel once all goroutines finish.
	go func() {
		wg.Wait()
		close(results)
	}()

	// Collect results, respecting context cancellation.
	var collected []string
	for r := range results {
		collected = append(collected, r)
	}

	return collected
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	ids := []string{"user-1", "user-2", "user-3"}
	results := RunBatch(ctx, ids)

	for _, r := range results {
		fmt.Println(r)
	}
}

The context.Context parameter is always first. The receiver name is ctx by convention. Functions that accept a context must check for cancellation. The select statement in FetchProfile allows the goroutine to exit if the context is done, preventing leaks.

The loop variable id is passed as an argument to the anonymous function. In older versions of Go, capturing the loop variable directly caused all goroutines to see the final value. Go 1.22 changed this behavior. The compiler now rejects the old pattern with loop variable i captured by func literal. Passing the variable as an argument is the correct fix and works in all versions.

The results channel is buffered to match the number of IDs. This prevents goroutines from blocking on send if the collector is slow. The channel closes after wg.Wait() returns. Closing signals the range loop to exit.

Pitfalls that break production

Goroutine bugs often hide until load increases. The compiler cannot catch all concurrency errors. You must know the failure modes.

Data races

A data race occurs when two goroutines access the same memory location concurrently, and at least one access is a write. The compiler does not detect this. The program compiles and runs. The behavior is undefined. You might see corrupted data, panics, or silent failures.

Run your code with the race detector: go run -race main.go. The detector instruments the binary and reports violations at runtime. The output includes WARNING: DATA RACE followed by the stack traces of the conflicting accesses.

Fix data races by adding a mutex around the shared data or by using channels to pass ownership. Maps are a common source of races. Accessing a map from multiple goroutines without a mutex causes a runtime panic with fatal error: concurrent map writes. Always protect maps with a mutex or use sync.Map if the access pattern fits.

Goroutine leaks

A goroutine leak happens when a goroutine runs forever because it waits on a condition that never becomes true. The goroutine holds resources, memory, and file descriptors. Over time, the process exhausts memory and crashes.

Leaks often occur in channels. If a goroutine blocks on ch <- value and no one reads from ch, the goroutine hangs. If the channel is unbuffered and the receiver has exited, the sender blocks forever.

Use context.Context to provide a cancellation path. Wrap blocking operations in a select with ctx.Done(). If the context cancels, the goroutine exits. The worst goroutine bug is the one that never logs. Always ensure goroutines can exit when their parent scope ends.

Loop variable capture

Before Go 1.22, the loop variable in a for loop was shared across all iterations. If you started a goroutine inside the loop and captured the variable, all goroutines saw the final value.

// BAD: All goroutines print the last value of i.
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i)
    }()
}

The fix is to pass the variable as an argument.

// GOOD: Each goroutine captures its own copy of i.
for i := 0; i < 3; i++ {
    go func(i int) {
        fmt.Println(i)
    }(i)
}

Go 1.22 changed the loop semantics. The compiler now creates a new variable for each iteration. The old code works correctly in 1.22+. However, the compiler rejects the old pattern with loop variable i captured by func literal to encourage explicit capture. Use the argument pattern for clarity and compatibility.

Silent exits

The main goroutine controls the program lifecycle. When main returns, the program exits immediately. All other goroutines are killed. There is no cleanup. No finalizers run. Data in flight is lost.

If you forget to wait for goroutines, the program might finish before work completes. You see no error. You just get wrong results. Always use a WaitGroup or a channel to synchronize with background work. If you spawn a goroutine for a long-running task, ensure it has a way to signal completion or cancellation.

Error handling in Go is explicit. The pattern if err != nil { return err } is verbose by design. It makes the unhappy path visible. Do not ignore errors in goroutines. Return the error to a channel or log it. Silently swallowing errors makes debugging impossible.

Decision matrix

Concurrency tools solve specific problems. Pick the right tool for the job.

Use a goroutine when you have independent work that can run in parallel or wait for I/O without blocking the caller. Use a mutex when multiple goroutines must read and write the same variable, and the access pattern is simple locking without complex signaling. Use a channel when you need to pass data between goroutines or coordinate based on the arrival of a value. Use a WaitGroup when you need to wait for a set of goroutines to finish before proceeding. Use context when you need to propagate cancellation signals or deadlines through a call tree. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Goroutines are cheap. Channels are not magic. Context is plumbing. Run it through every long-lived call site.

Where to go next