Skip to main content
⚡ Calmops

Common Go Pitfalls and How to Avoid Them

Introduction

Go is designed to be simple, but several patterns trip up developers regularly — even experienced ones. This guide covers the pitfalls that cause the most bugs in production Go code, with clear before/after examples.

1. Loop Variable Capture in Goroutines

The most common Go bug: closures in loops capture the variable reference, not the value.

// BAD: all goroutines print the same final value of i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)  // captures &i, not the value of i
    }()
}
// Output: 5 5 5 5 5 (or similar — all the same)

// GOOD: pass i as an argument to capture the value
for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n)  // n is a copy of i at this iteration
    }(i)
}
// Output: 0 1 2 3 4 (in some order)

// ALSO GOOD: create a new variable in the loop body
for i := 0; i < 5; i++ {
    i := i  // shadows outer i with a new variable
    go func() {
        fmt.Println(i)
    }()
}

Note: Go 1.22+ fixes this — loop variables are now per-iteration by default. But code targeting older versions still needs the workaround.

Same Issue with range

// BAD: all closures capture the same dir variable
var rmdirs []func()
for _, dir := range tempDirs() {
    os.MkdirAll(dir, 0755)
    rmdirs = append(rmdirs, func() {
        os.RemoveAll(dir)  // captures &dir — all use the last value!
    })
}

// GOOD: create a new variable
for _, dir := range tempDirs() {
    dir := dir  // new variable per iteration
    os.MkdirAll(dir, 0755)
    rmdirs = append(rmdirs, func() {
        os.RemoveAll(dir)  // captures this iteration's dir
    })
}

2. The Nil Interface Trap

An interface value is nil only when both its type AND value are nil. A typed nil is not nil:

type Animal interface {
    Sound() string
}

type Dog struct{}
func (d *Dog) Sound() string { return "Woof" }

func getAnimal(want bool) Animal {
    var d *Dog  // d is nil (typed nil pointer)
    if want {
        return d  // returns non-nil interface! (type=*Dog, value=nil)
    }
    return nil  // returns true nil interface
}

a := getAnimal(true)
fmt.Println(a == nil)  // false! interface has type *Dog
a.Sound()              // panic: nil pointer dereference inside Sound()
// GOOD: return nil directly, not a typed nil
func getAnimal(want bool) Animal {
    if want {
        return &Dog{}  // non-nil pointer
    }
    return nil  // true nil interface
}

// Or check before returning
func getAnimal(want bool) Animal {
    var d *Dog
    if want {
        d = &Dog{}
    }
    if d == nil {
        return nil  // return nil interface, not typed nil
    }
    return d
}

3. Goroutine Leaks

Goroutines that never exit are a memory leak. The most common cause: sending to a channel nobody reads.

// BAD: goroutine leaks if caller returns before reading
func startWork() <-chan int {
    ch := make(chan int)
    go func() {
        result := expensiveComputation()
        ch <- result  // blocks forever if nobody reads ch
    }()
    return ch
}

// If caller abandons the channel, the goroutine is stuck forever

// GOOD: use a buffered channel so goroutine doesn't block
func startWork() <-chan int {
    ch := make(chan int, 1)  // buffer of 1
    go func() {
        ch <- expensiveComputation()  // won't block
    }()
    return ch
}

// BETTER: use context for cancellation
func startWork(ctx context.Context) <-chan int {
    ch := make(chan int, 1)
    go func() {
        select {
        case ch <- expensiveComputation():
        case <-ctx.Done():
            // caller cancelled — goroutine exits cleanly
        }
    }()
    return ch
}

Detect leaks: Use goleak in tests:

func TestMyFunc(t *testing.T) {
    defer goleak.VerifyNone(t)  // fails if goroutines leak
    myFunc()
}

4. Slice Sharing and Unexpected Mutations

Slices share underlying arrays. Appending to a sub-slice can overwrite the original:

original := []int{1, 2, 3, 4, 5}
sub := original[:3]  // [1, 2, 3] — shares original's array

sub = append(sub, 99)  // within capacity — overwrites original[3]!
fmt.Println(original)  // [1 2 3 99 5] — original was modified!
// GOOD: use three-index slice to limit capacity
sub := original[:3:3]  // len=3, cap=3 — append will allocate new array
sub = append(sub, 99)
fmt.Println(original)  // [1 2 3 4 5] — unchanged

// GOOD: copy explicitly
sub := make([]int, 3)
copy(sub, original[:3])

5. Defer in Loops

defer runs at function return, not at the end of a loop iteration. This causes resource leaks:

// BAD: files are only closed when the function returns, not each iteration
func processFiles(paths []string) error {
    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close()  // deferred until function returns — all files stay open!
        process(f)
    }
    return nil
}

// GOOD: use a helper function so defer runs per iteration
func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processFile(path); err != nil {
            return err
        }
    }
    return nil
}

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()  // runs when processFile returns
    return process(f)
}

6. Ignoring Errors

Go makes errors explicit, but it’s tempting to ignore them:

// BAD: silently ignores errors
result, _ := doSomething()
os.Remove(tmpFile)  // error ignored

// GOOD: handle or propagate errors
result, err := doSomething()
if err != nil {
    return fmt.Errorf("doSomething: %w", err)
}

if err := os.Remove(tmpFile); err != nil {
    log.Printf("failed to remove temp file %s: %v", tmpFile, err)
}

Use errcheck to find ignored errors:

go install github.com/kisielk/errcheck@latest
errcheck ./...

7. Map Concurrent Access

Maps are not safe for concurrent read/write:

// BAD: data race — concurrent map access panics
m := make(map[string]int)
go func() { m["key"] = 1 }()
go func() { _ = m["key"] }()
// fatal error: concurrent map read and map write

// GOOD: use sync.RWMutex
type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}

func (s *SafeMap) Set(key string, val int) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = val
}

func (s *SafeMap) Get(key string) (int, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

// ALSO GOOD: sync.Map for high-concurrency read-heavy workloads
var m sync.Map
m.Store("key", 1)
val, ok := m.Load("key")

8. Integer Overflow

Go doesn’t panic on integer overflow — it wraps silently:

var x int8 = 127
x++
fmt.Println(x)  // -128 (wrapped around!)

// GOOD: use appropriate types and check bounds
func safeAdd(a, b int) (int, error) {
    if b > 0 && a > math.MaxInt-b {
        return 0, errors.New("integer overflow")
    }
    if b < 0 && a < math.MinInt-b {
        return 0, errors.New("integer underflow")
    }
    return a + b, nil
}

9. String Indexing Returns Bytes, Not Runes

s := "Hello, 世界"
fmt.Println(len(s))     // 13 (bytes, not characters!)
fmt.Println(s[7])       // 228 (first byte of 世, not the character)

// GOOD: use range to iterate over runes
for i, r := range s {
    fmt.Printf("%d: %c\n", i, r)
}

// GOOD: convert to []rune for character indexing
runes := []rune(s)
fmt.Println(len(runes))  // 9 (characters)
fmt.Println(string(runes[7]))  // 世

10. Shadowing err in Multi-Return Assignments

// BAD: := creates a new err variable, outer err is unchanged
func process() error {
    var err error

    result, err := step1()  // ok — assigns to outer err
    if err != nil { return err }

    // BUG: := creates a NEW err variable in this scope
    if result > 0 {
        data, err := step2()  // new err, shadows outer err
        if err != nil { return err }
        use(data)
    }
    // outer err is still nil here even if step2 failed!
    return err
}

// GOOD: use = for existing variables
func process() error {
    result, err := step1()
    if err != nil { return err }

    if result > 0 {
        var data []byte
        data, err = step2()  // = assigns to outer err
        if err != nil { return err }
        use(data)
    }
    return nil
}

Tools to Catch These Issues

# Run all standard checks
go vet ./...

# golangci-lint: runs many linters at once
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run

# Race detector: catches concurrent map access and data races
go test -race ./...
go run -race main.go

# Goroutine leak detection in tests
go get go.uber.org/goleak

Resources

Comments