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.modsaysgo 1.22or later, you never need thev := vshadow 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
| Gotcha | Fix |
|---|---|
| Nil interface not nil | Return bare nil, not typed nil pointer |
| Goroutine leak | Always use context with cancel/timeout |
| Slice append mutates | Use s[:n:n] or copy |
| Map order random | Sort keys if order matters |
| Defer evaluates args early | Use closure to capture latest value |
Next: Error Handling | Concurrency