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
Comments