Gotchas

Common pitfalls in Go and recent language changes.

Nil Interface Trap

An interface is nil only when both its type and value are nil. This is the single most common source of unexpected nil behavior:

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

func mayFail() error {
    var err *MyError = nil
    return err // returns a non-nil interface!
}

func main() {
    err := mayFail()
    fmt.Println(err == nil) // false!
}

The error interface returned has type *MyError and value nil. Since the type is set, the interface is not nil.

Fix: Return nil explicitly, not a typed nil pointer:

func mayFail() error {
    var err *MyError
    if somethingFailed {
        err = &MyError{"bad"}
    }
    if err != nil {
        return err
    }
    return nil // return bare nil, not a typed nil pointer
}

Goroutine Leaks

A goroutine that blocks forever is a memory leak. Common causes:

// Leak: channel send with no receiver
func leak() {
    ch := make(chan int)
    go func() {
        ch <- 42 // blocks forever if nobody reads ch
    }()
    // ch goes out of scope, goroutine is stuck
}

// Leak: missing context cancellation
func leak2() {
    ctx := context.Background() // never cancelled
    go func() {
        ticker := time.NewTicker(time.Second)
        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                doWork()
            }
        }
    }()
    // goroutine runs forever
}

Fix: Always use context.WithCancel or context.WithTimeout and call cancel():

func noLeak() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    ch := make(chan int, 1) // buffered so send doesn't block
    go func() {
        select {
        case ch <- 42:
        case <-ctx.Done():
        }
    }()
}

Use go.uber.org/goleak to detect leaks in tests:

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

Slice Append Gotcha

append may or may not allocate a new backing array. Slicing a slice shares the underlying array:

original := []int{1, 2, 3, 4, 5}
slice := original[:3] // [1, 2, 3] - shares backing array

slice = append(slice, 99)
fmt.Println(original) // [1, 2, 3, 99, 5] - original is modified!

This happens because slice has capacity 5 (inherited from original), so append writes into the existing array.

Fix: Use a full slice expression to limit capacity:

slice := original[:3:3] // length 3, capacity 3
slice = append(slice, 99)
fmt.Println(original) // [1, 2, 3, 4, 5] - unchanged

Or copy explicitly:

slice := make([]int, 3)
copy(slice, original[:3])

Range Loop Variable Scoping (Fixed in Go 1.22)

Before Go 1.22, loop variables were reused across iterations. This caused bugs with goroutines and closures:

// Before Go 1.22 - BUG: all goroutines print the same value
for _, v := range values {
    go func() {
        fmt.Println(v) // captures the loop variable, not its value
    }()
}

// Before Go 1.22 - fix was to copy the variable
for _, v := range values {
    v := v // shadow with a copy
    go func() {
        fmt.Println(v)
    }()
}

Since Go 1.22, each iteration gets its own variable. The bug is gone.

Tip: If your go.mod says go 1.22 or later, you never need the v := v shadow trick.

Map Iteration Order

Map iteration order is randomized. Never depend on it:

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v) // different order each run
}

If you need sorted output:

keys := slices.Sorted(maps.Keys(m))
for _, k := range keys {
    fmt.Println(k, m[k])
}

Deferred Function Arguments

Arguments to deferred functions are evaluated immediately, not when the deferred function runs:

func example() {
    x := 1
    defer fmt.Println(x) // prints 1, not 2
    x = 2
}

To capture the final value, use a closure:

func example() {
    x := 1
    defer func() { fmt.Println(x) }() // prints 2
    x = 2
}

Go 1.25 Changes

Container-Aware GOMAXPROCS

Go 1.25 automatically detects container CPU limits and sets GOMAXPROCS accordingly. Previously, a container with 2 CPU cores on a 64-core host would spawn 64 OS threads, causing excessive context switching.

Before 1.25, you needed go.uber.org/automaxprocs:

import _ "go.uber.org/automaxprocs"

With Go 1.25, this is built in. The runtime reads cgroup limits and adjusts.

Core Types Removed

Generic constraints that previously required a “core type” are now more flexible. Union constraints work in more places, making generics more useful. See Generics.

Go 1.26 Changes

new(expr) Syntax

Go 1.26 adds new(expr) as shorthand for creating a pointer to a value:

// Before: need a variable just to take its address
s := "hello"
ptr := &s

// Or the struct literal trick (only works for composites)
ptr := &MyStruct{Field: "value"}

// Go 1.26: new(expr) works for any expression
ptr := new("hello")        // *string
ptr := new(42)             // *int
ptr := new(time.Now())     // *time.Time

This is especially useful for optional fields in API structs:

type UpdateRequest struct {
    Name  *string `json:"name,omitempty"`
    Count *int    `json:"count,omitempty"`
}

req := UpdateRequest{
    Name:  new("updated"),
    Count: new(5),
}

Green Tea GC

Go 1.26 introduces the Green Tea garbage collector, a major redesign targeting lower tail latency. Key improvements:

  • Reduced p99 GC pause times (targeting sub-100 microsecond pauses)
  • Better memory locality through per-goroutine allocation regions
  • Reduced GC CPU overhead for allocation-heavy workloads

No code changes needed - it is the default GC in 1.26.

Self-Referential Generic Types

Types can now reference themselves in generic type parameters, enabling patterns like protocol buffers’ ProtoReflect and fluent builder APIs. See Generics.

Quick Reference

GotchaFix
Nil interface not nilReturn bare nil, not typed nil pointer
Goroutine leakAlways use context with cancel/timeout
Slice append mutatesUse s[:n:n] or copy
Map order randomSort keys if order matters
Defer evaluates args earlyUse closure to capture latest value

Next: Error Handling | Concurrency