11. Common Pitfalls

Frequent mistakes and gotchas to recognize quickly and avoid in production code.

Question: What is the "loop variable capture" pitfall in Go, and how do you avoid it?

Answer: When using goroutines or closures inside a for loop, if the closure captures the loop variable directly, it will capture a reference to the same variable that is being updated on each iteration. By the time the goroutines execute, the loop may have finished, and they will all see the variable's final value.

Explanation: To fix this, you must create a new copy of the variable for each iteration. The two common ways are to pass the loop variable as an argument to the goroutine, or to "shadow" it inside the loop with v := v.

values := []string{"a", "b", "c"}

// Incorrect: all goroutines will likely print "c"
for _, v := range values {
    go func() { fmt.Println(v) }()
}

// Correct: pass v as an argument
for _, v := range values {
    go func(val string) { fmt.Println(val) }(v)
}

// Correct (alternative): shadow the variable
for _, v := range values {
    v := v // Create a new `v` for this iteration
    go func() { fmt.Println(v) }()
}

Question: What is a common pitfall when appending to a slice that was created by slicing another slice?

Answer: When you create a slice b from a slice a (e.g., b := a[:2]), they both share the same underlying array. If you then append to slice b and its capacity has not been exceeded, you will overwrite the values in the original slice a that exist beyond b's length.

Explanation: This happens because the slice's capacity extends to the end of the underlying array. Appending within that capacity modifies the shared array. To prevent this, you can use a "full slice expression" (a[low:high:max]) to explicitly set the capacity of the new slice, or simply create a fresh copy of the slice using make and copy.

s1 := []int{1, 2, 3, 4}
s2 := s1[:2] // s2 is {1, 2}, cap is 4, shares with s1
s2 = append(s2, 99) // s2 is {1, 2, 99}
// s1 is now {1, 2, 99, 4} -- s1[2] was overwritten!

Question: What is the difference between a nil slice and an empty slice?

Answer: A nil slice has no underlying array; its pointer is nil. An empty slice is a slice with a length of 0, but it may have an underlying array (with a size of 0). For most practical purposes, like using len() or ranging over them, they behave identically.

Explanation: A key difference appears during JSON marshaling. By default, a nil slice is marshaled to null, while an empty slice is marshaled to []. This can be an important distinction when designing APIs. The len() and cap() of both a nil slice and an empty slice are 0.

var nilSlice []int
emptySlice := make([]int, 0)

fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false

Question: Why should you be careful when using defer inside a loop?

Answer: A defer statement schedules a function call to execute when the surrounding function returns, not when the current loop iteration ends. If you use defer inside a long-running loop, it can lead to a large accumulation of deferred calls, which consumes memory and can delay the cleanup of resources.

Explanation: For example, if you defer f.Close() on a file handle inside a for loop, none of the files will be closed until the entire function containing the loop finishes. The correct pattern is to either call the cleanup function directly at the end of the loop or refactor the loop body into its own function where defer can be used safely.

Question: Why can an error be non-nil even if the underlying pointer is nil?

Answer: An interface is a pair of (type, value). If the dynamic type is set (e.g., *MyError) but the value is nil, the interface itself is non-nil.

Explanation: Returning a typed nil as an error yields a non-nil interface. Ensure you return a plain nil interface when there is no error.

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func f() error {
    var e *MyError = nil
    // return e        // NON-NIL interface (type=*MyError, value=nil)
    return nil         // correct: no error
}

Question: What happens when you send on a closed channel or close a closed channel?

Answer: Sending on a closed channel panics; closing an already closed channel panics. Only the sender should close.

Question: Why is time.After in a loop a leak, and what to use instead?

Answer: time.After allocates a timer each iteration; if the select case isn’t taken, timers pile up. Use a single time.NewTimer/Reset or time.Ticker and Stop() it.

Question: Why is copying a struct with a sync.Mutex dangerous?

Answer: Copying a value containing a mutex duplicates its internal state and can deadlock or panic. Do not copy types with mutexes; pass pointers.

Question: What is the pointer-to-interface vs interface-containing-pointer gotcha?

Answer: A *I (pointer to interface) is almost never what you want. Interfaces are already references. Prefer I where I may hold a *T.