Language

Go Gotchas

Tricky corners of Go that trip up experienced developers. Each one includes the wrong behavior, why it happens, and the fix.

Slices Maps nil interfaces Closures Goroutines Integers Defer Strings
๐Ÿ”ช

Slice Gotchas

โ„น๏ธ
A 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

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

โ„น๏ธ
An 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

โš ๏ธ
Go 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

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

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

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

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 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

Gotcha Symptom Fix
Sub-slice sharingAppending to sub modifies originalUse three-index slice or copy()
nil vs empty sliceJSON null vs []Init with []T{} if you need []
Range copyModifying range value is a no-opUse index: slice[i].Field = x
nil map writepanic: assignment to nil mapmake(map[K]V) before first write
Map orderIteration differs every runSort keys first
Concurrent mapfatal: concurrent map writessync.Mutex or sync.Map
Typed nil in interfaceerr != nil even when pointer is nilReturn plain nil from functions
Type assertionpanic on wrong typev, ok := i.(T) โ€” comma-ok form
Loop variable captureAll closures print last valuei := i inside the loop (pre 1.22)
Goroutine loopAll goroutines see last valuePass v as argument to the goroutine
Goroutine leakGoroutine blocks foreverUse context or done channel
Send on closed channelpanic: send on closed channelOnly the sender should close
Deadlockfatal: all goroutines asleepUse goroutine or buffered channel
Integer overflowWraps silently, no panicCheck bounds with math.MaxTYPE
Integer division7/2 == 3, not 3.5float64(a) / float64(b)
Type conversionCompile error: mismatched typesExplicit cast: int64(x)
Defer in loopFiles stay open until function exitsWrap loop body in anonymous func
Defer arg evalDeferred arg captures old valueUse a closure to capture by reference
Named return + deferDefer silently changes the returnBe deliberate; prefer explicit returns
String indexings[i] is a byte, not a rune[]rune(s)[i] or range for runes
String concat loopO(nยฒ) performancestrings.Builder or strings.Join
Value receiverMethod does not modify structUse pointer receiver *T
Pointer method setT does not implement interfaceUse &T or switch to value receiver
Non-comparable structCompile error on == or map keyreflect.DeepEqual for comparison