Slice Gotchas
append ยท backing array ยท range copyA slice is a three-field struct: a pointer to an array, a length, and a capacity. Multiple slices can point at the same underlying array. That sharing is what makes most slice gotchas surprising.
Sub-slice shares the backing array
Gotcha
original := []int{1, 2, 3, 4, 5} sub := original[1:3] // [2 3] โ shares the same backing array sub[0] = 99 fmt.Println(original) // [1 99 3 4 5] โ original is modified! // append to sub overwrites original[3] if there is capacity sub = append(sub, 100) fmt.Println(original) // [1 99 3 100 5] โ surprise! // Fix: use the three-index slice expression to cap the capacity sub = original[1:3:3] // cap is now 2, append forces a new allocation sub = append(sub, 100) fmt.Println(original) // [1 99 3 4 5] โ original is safe // Or copy explicitly to detach from the original safe := make([]int, len(sub)) copy(safe, sub)
nil slice vs empty slice
nil vs empty
var nilSlice []int // nil โ pointer is nil emptySlice := []int{} // not nil โ pointer is non-nil but len is 0 fmt.Println(nilSlice == nil) // true fmt.Println(emptySlice == nil) // false fmt.Println(len(nilSlice)) // 0 โ both have length 0 fmt.Println(len(emptySlice)) // 0 // Both work fine for range and append nilSlice = append(nilSlice, 1) // safe for _, v := range nilSlice { _ = v } // safe // JSON: nil marshals to null, empty marshals to [] json.Marshal(nilSlice) // null json.Marshal(emptySlice) // [] // This distinction matters when the API consumer checks for null
Range gives a copy โ mutations are lost
Gotcha
type Point struct{ X, Y int } points := []Point{{1, 2}, {3, 4}} // p is a copy โ modifying it has no effect on the slice for _, p := range points { p.X = 99 } fmt.Println(points) // [{1 2} {3 4}] โ unchanged // Fix: use the index to modify in place for i := range points { points[i].X = 99 } fmt.Println(points) // [{99 2} {99 4}] // Or use a slice of pointers if you pass elements around ppoints := []*Point{{1, 2}, {3, 4}} for _, p := range ppoints { p.X = 99 // p is a pointer copy โ but it still points to the original }
Map Gotchas
nil ยท iteration order ยท concurrency
Writing to a nil map panics
Gotcha
var m map[string]int // nil map // Reading is safe โ returns the zero value fmt.Println(m["key"]) // 0 v, ok := m["key"] // v=0, ok=false // Writing panics m["key"] = 1 // panic: assignment to entry in nil map // Always initialize before writing m = make(map[string]int) m["key"] = 1 // safe // Or initialize with a literal m = map[string]int{"key": 1}
Iteration order is not guaranteed
Non-deterministic
m := map[string]int{"a": 1, "b": 2, "c": 3} // Go deliberately randomizes map iteration order // This output changes on every run for k, v := range m { fmt.Println(k, v) // could be b,a,c or c,b,a โ unpredictable } // Fix: collect keys, sort them, then iterate keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Println(k, m[k]) // a 1, b 2, c 3 โ deterministic }
Concurrent map access causes a runtime panic
Race condition
m := make(map[string]int) // Concurrent writes โ fatal error at runtime go func() { m["a"] = 1 }() go func() { m["b"] = 2 }() // fatal error: concurrent map writes // Fix 1: protect with a mutex var mu sync.RWMutex go func() { mu.Lock() m["a"] = 1 mu.Unlock() }() // Fix 2: use sync.Map for high-contention scenarios var sm sync.Map sm.Store("a", 1) v, _ := sm.Load("a")
Interface & nil
typed nil ยท type assertion ยท comparisonAn interface value holds two fields internally: a type and a value. An interface is nil only when both fields are nil. A typed nil pointer stored in an interface has a non-nil type field, so the interface itself is not nil.
Typed nil in an interface is not nil
Classic trap
type MyError struct{ msg string } func (e *MyError) Error() string { return e.msg } func getError() error { var e *MyError // typed nil pointer return e // wraps (*MyError, nil) in an error interface } err := getError() fmt.Println(err == nil) // false โ surprise! type part is *MyError fmt.Println(err) // <nil> โ value part prints as nil // Fix: return an untyped nil func getError() error { var e *MyError if e == nil { return nil // returns (nil, nil) โ the interface is truly nil } return e } err = getError() fmt.Println(err == nil) // true
Type assertion without comma-ok panics
Gotcha
var i interface{} = "hello" // Single-value assertion panics when the type is wrong n := i.(int) // panic: interface {} is string, not int // Safe form: comma-ok never panics n, ok := i.(int) if !ok { fmt.Println("not an int") // prints this } // Type switch is another safe alternative โ handles multiple types switch v := i.(type) { case int: fmt.Println("int:", v) case string: fmt.Println("string:", v) // string: hello default: fmt.Printf("unknown: %T\n", v) }
Closures & Loops
variable capture ยท goroutine loops ยท Go 1.22Go 1.22 changed loop variable semantics so each iteration gets its own copy. If you are on Go 1.21 or earlier the captures below are real bugs. Even on 1.22+ the goroutine timing issue still bites you in
for range over channels.
All closures capture the same loop variable (pre 1.22)
Classic trap
// All three functions share a reference to the same i funcs := make([]func(), 3) for i := 0; i < 3; i++ { funcs[i] = func() { fmt.Println(i) } } funcs[0]() // 3 โ not 0, i is 3 when funcs run funcs[1]() // 3 funcs[2]() // 3 // Fix: shadow the variable inside the loop body for i := 0; i < 3; i++ { i := i // new variable per iteration funcs[i] = func() { fmt.Println(i) } } funcs[0]() // 0 funcs[1]() // 1 funcs[2]() // 2 // Alternative fix: pass i as an argument for i := 0; i < 3; i++ { funcs[i] = func(n int) func() { return func() { fmt.Println(n) } }(i) }
Goroutines in loops print the wrong value
Gotcha
words := []string{"a", "b", "c"} // Goroutines start AFTER the loop finishes โ v is already "c" for _, v := range words { go func() { fmt.Println(v) // prints "c c c" (or similar) }() } // Fix 1: pass v as an argument โ evaluated at goroutine launch time for _, v := range words { go func(v string) { fmt.Println(v) // prints a, b, c in some order }(v) } // Fix 2: shadow v inside the loop for _, v := range words { v := v go func() { fmt.Println(v) }() }
Goroutines & Channels
leaks ยท closed channel ยท deadlock
Goroutine leak โ blocked with no sender
Memory leak
// Goroutine blocks on receive forever โ it leaks func leak() { ch := make(chan int) go func() { val := <-ch // blocked forever, goroutine never exits fmt.Println(val) }() // ch never receives a value โ function returns, goroutine stays } // Fix: use context to give the goroutine an exit path func noLeak(ctx context.Context) { ch := make(chan int) go func() { select { case val := <-ch: fmt.Println(val) case <-ctx.Done(): return // exits cleanly when context is cancelled } }() }
Sending on a closed channel panics
Gotcha
ch := make(chan int, 1) ch <- 1 close(ch) // Sending to a closed channel always panics ch <- 2 // panic: send on closed channel // Receiving from a closed channel is safe v, ok := <-ch fmt.Println(v, ok) // 1 true โ buffered value still there v, ok = <-ch fmt.Println(v, ok) // 0 false โ empty and closed // range over a closed channel drains it then stops ch2 := make(chan int, 3) ch2 <- 1; ch2 <- 2; close(ch2) for v := range ch2 { fmt.Println(v) // 1, 2 }
Deadlock โ all goroutines asleep
Deadlock
// Sending on an unbuffered channel with no receiver blocks forever ch := make(chan int) ch <- 1 // fatal error: all goroutines are asleep โ deadlock! // Fix 1: use a goroutine to send ch := make(chan int) go func() { ch <- 1 }() v := <-ch // 1 // Fix 2: use a buffered channel ch := make(chan int, 1) ch <- 1 // does not block v := <-ch // Mutual deadlock โ two goroutines waiting on each other ch1 := make(chan int) ch2 := make(chan int) go func() { <-ch1; ch2 <- 1 }() go func() { <-ch2; ch1 <- 1 }() // Both goroutines wait forever
Integers & Types
overflow ยท division ยท no implicit conversion
Integer overflow wraps silently
Silent bug
// Go does not panic on overflow โ it wraps around var x uint8 = 255 x++ fmt.Println(x) // 0 var y int8 = 127 y++ fmt.Println(y) // -128 // Use math constants to guard against overflow import "math" if x > math.MaxUint8-1 { fmt.Println("would overflow") } // The race detector and go vet do NOT catch overflow // Consider using big.Int for arbitrary precision math
Integer division truncates
Truncation
a := 7 / 2 fmt.Println(a) // 3, not 3.5 b := -7 / 2 fmt.Println(b) // -3 (toward zero) // Fix: convert to float64 first c := float64(7) / 2 fmt.Println(c) // 3.5 // Modulo follows the sign of the dividend fmt.Println(-7 % 3) // -1, not 2
No implicit type conversion
Compile error
var a int = 10 var b int64 = 20 // Does not compile _ = a + b // mismatched types int and int64 // Must convert explicitly _ = int64(a) + b // 30 var f float64 = 3.9 n := int(f) // 3 โ truncates, no rounding // byte is uint8 โ common in string manipulation var c byte = 'A' // 65 _ = c + 1 // 66 โ byte arithmetic wraps at 255
Defer Gotchas
loop defer ยท args evaluated ยท named returns
Defer in a loop does not run per iteration
Resource leak
// All defers pile up and run when the FUNCTION exits, not each iteration func processFiles(paths []string) { for _, p := range paths { f, _ := os.Open(p) defer f.Close() // all 100 files stay open until processFiles returns } } // Fix: wrap the body in an immediately-invoked function func processFiles(paths []string) { for _, p := range paths { func() { f, _ := os.Open(p) defer f.Close() // closes at the end of each iteration // ... process file ... }() } }
Arguments evaluated at defer time
Evaluated now
x := 1 defer fmt.Println("deferred:", x) // x=1 captured now x = 2 fmt.Println("current:", x) // 2 // Output: // current: 2 // deferred: 1 // Use a closure to read x at call time instead x = 1 defer func() { fmt.Println("deferred:", x) }() x = 2 // Output: // deferred: 2
Defer can change named return values
Side effect
// Deferred closure can read and modify named returns func withBonus(price float64) (result float64) { defer func() { result *= 1.1 // runs after return, adds 10% }() result = price return // returns price * 1.1 } fmt.Println(withBonus(100)) // 110, not 100 // This is how recover() can set an error on return func safe() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() panic("boom") }
String Gotchas
bytes vs runes ยท concatenation ยท immutability
Indexing gives bytes, not characters
Gotcha
s := "Hello, ไธ็" // s[i] returns a byte (uint8) โ not a rune fmt.Println(s[7]) // 228 โ first byte of 'ไธ', not 'ไธ' fmt.Printf("%T\n", s[7]) // uint8 // len() counts bytes, not characters fmt.Println(len(s)) // 13, not 9 // range over a string decodes UTF-8 and yields runes for i, r := range s { fmt.Printf("%d: %c\n", i, r) // 7: ไธ (byte index 7) } // Fix: convert to []rune for character-index access runes := []rune(s) fmt.Println(len(runes)) // 9 fmt.Println(string(runes[7])) // ไธ
String concatenation in a loop is O(nยฒ)
Performance trap
parts := []string{"a", "b", "c", "d"} // Slow: each += allocates a new string and copies all previous bytes result := "" for _, s := range parts { result += s // O(nยฒ) total โ don't do this for large inputs } // Fast: strings.Builder โ single growing buffer var b strings.Builder for _, s := range parts { b.WriteString(s) } result = b.String() // one final allocation // Cleanest when joining a known slice result = strings.Join(parts, "") // abcd
Converting between string and []byte copies
Hidden allocation
s := "hello" // string โ []byte makes a copy โ mutations don't affect s b := []byte(s) b[0] = 'H' fmt.Println(s) // "hello" โ unchanged fmt.Println(string(b)) // "Hello" // Each conversion allocates, so avoid in hot paths // Use strings.Builder or []byte throughout instead // strings.Contains, strings.HasPrefix etc. accept string โ no conversion needed strings.Contains(s, "ell") // true โ no []byte needed // bytes package has the same functions for []byte bytes.Contains(b, []byte("ell")) // true
Struct & Method Gotchas
value vs pointer receiver ยท method set ยท embedding
Value receiver does not modify the original
Gotcha
type Counter struct{ n int } // Value receiver โ operates on a copy func (c Counter) Increment() { c.n++ // modifies the copy, not the original } func (c *Counter) IncrementPtr() { c.n++ // modifies the original via pointer } c := Counter{} c.Increment() fmt.Println(c.n) // 0 โ unchanged c.IncrementPtr() fmt.Println(c.n) // 1 โ modified
Interface satisfaction depends on receiver type
Compile error
type Stringer interface { String() string } type Name struct{ val string } func (n *Name) String() string { return n.val } // pointer receiver var s Stringer = Name{"go"} // compile error: Name does not implement Stringer var s Stringer = &Name{"go"} // ok: *Name implements Stringer // Rule: pointer methods are in the method set of *T, not T // Value methods are in the method set of both T and *T // Fix: use a pointer when your method needs to mutate, OR // change the method to a value receiver if no mutation needed func (n Name) String() string { return n.val } var s Stringer = Name{"go"} // now both Name and *Name satisfy the interface
Struct comparison requires all fields to be comparable
Compile error
type Good struct{ X, Y int } type Bad struct{ Data []int } // slices are not comparable a, b := Good{1, 2}, Good{1, 2} fmt.Println(a == b) // true c, d := Bad{[]int{1}}, Bad{[]int{1}} _ = c == d // compile error: struct containing []int cannot be compared // Fix: use reflect.DeepEqual for deep comparison reflect.DeepEqual(c, d) // true // Maps and slices as struct fields also block use as map keys var m map[Bad]int // compile error: Bad contains []int
Quick Reference
Gotchas cheat-sheet| Gotcha | Symptom | Fix |
|---|---|---|
| Sub-slice sharing | Appending to sub modifies original | Use three-index slice or copy() |
| nil vs empty slice | JSON null vs [] | Init with []T{} if you need [] |
| Range copy | Modifying range value is a no-op | Use index: slice[i].Field = x |
| nil map write | panic: assignment to nil map | make(map[K]V) before first write |
| Map order | Iteration differs every run | Sort keys first |
| Concurrent map | fatal: concurrent map writes | sync.Mutex or sync.Map |
| Typed nil in interface | err != nil even when pointer is nil | Return plain nil from functions |
| Type assertion | panic on wrong type | v, ok := i.(T) โ comma-ok form |
| Loop variable capture | All closures print last value | i := i inside the loop (pre 1.22) |
| Goroutine loop | All goroutines see last value | Pass v as argument to the goroutine |
| Goroutine leak | Goroutine blocks forever | Use context or done channel |
| Send on closed channel | panic: send on closed channel | Only the sender should close |
| Deadlock | fatal: all goroutines asleep | Use goroutine or buffered channel |
| Integer overflow | Wraps silently, no panic | Check bounds with math.MaxTYPE |
| Integer division | 7/2 == 3, not 3.5 | float64(a) / float64(b) |
| Type conversion | Compile error: mismatched types | Explicit cast: int64(x) |
| Defer in loop | Files stay open until function exits | Wrap loop body in anonymous func |
| Defer arg eval | Deferred arg captures old value | Use a closure to capture by reference |
| Named return + defer | Defer silently changes the return | Be deliberate; prefer explicit returns |
| String indexing | s[i] is a byte, not a rune | []rune(s)[i] or range for runes |
| String concat loop | O(nยฒ) performance | strings.Builder or strings.Join |
| Value receiver | Method does not modify struct | Use pointer receiver *T |
| Pointer method set | T does not implement interface | Use &T or switch to value receiver |
| Non-comparable struct | Compile error on == or map key | reflect.DeepEqual for comparison |