Six Go Gotchas the Compiler Won't Catch
Six Go patterns that compile cleanly and quietly do the wrong thing: loop capture, typed nil, slice aliasing, shadowed err, defer in a loop, and range copies.
Go’s compiler catches a lot. It does not catch these six. Each one compiles fine, passes a quick glance in review, and produces the wrong answer only when you actually run it.
Loop Variables and Goroutines
The classic version of this bug: every goroutine in the loop reads the same shared variable, so they all print the last item.
for _, item := range items { go func() { fmt.Println(item) }()}Go 1.22 changed the semantics so each iteration gets its own item, and this now prints what you’d expect. But the fix is gated by the go directive in go.mod. If that file still says go 1.20, you get the old per-loop variable even on a brand new toolchain. I got bitten by this after upgrading Go locally but not bumping go.mod — the bug was “fixed” on my machine and not fixed in CI.
If you’re not sure which behavior you’re getting, pass the loop variable in explicitly. It works under both semantics and costs nothing:
for _, item := range items { go func(item string) { fmt.Println(item) }(item)}A Nil Pointer Is Not a Nil Interface
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func doSomething() error { var err *MyError = nil return err}
func main() { err := doSomething() fmt.Println(err == nil) // false}err here is an interface value with a concrete type (*MyError) and a nil pointer inside it. The interface itself is not nil, because it knows its type. This is why “return a typed nil as an error” is a footgun and not just a style nitpick — the caller’s if err != nil check passes even though nothing went wrong. The fix is boring: return the untyped nil literal, not a nil variable of a concrete type.
Append Can Silently Overwrite a Shared Array
a := []int{1, 2, 3}b := a[:2]b = append(b, 99)fmt.Println(a) // [1 2 99]b is a slice over the same backing array as a, with capacity 3. Appending one element fits inside that capacity, so Go reuses the array instead of allocating a new one — and a[2] gets clobbered. Nothing here panics or warns you. It only shows up as data that changed somewhere you never touched it. If you’re handing a sub-slice to something that might grow it, copy it first or reslice with a capacity limit (a[:2:2]) to force a reallocation on append.
Shadowed err Swallows the Result You Wanted
data, err := fetch()if err != nil { return err}
if data, err := transform(data); err != nil { return err}
return process(data) // still the ORIGINAL data, transform's output is gone:= inside the if only needs one new variable to be legal, and being in a new block scope means both data and err count as new there — even though identically named variables exist outside. The transformed value never escapes the if. This one is nasty specifically because it compiles silently and the success path looks correct; it only breaks once transform actually changes something.
defer Inside a Loop Doesn’t Run Per Iteration
func processFiles(paths []string) error { for _, p := range paths { f, err := os.Open(p) if err != nil { return err } defer f.Close() // process f } return nil}defer runs when the enclosing function returns, not when the loop iteration ends. Loop over a few hundred files and you’ll hold a few hundred open file descriptors until processFiles itself returns, which is how I once got a “too many open files” error from a function that looked completely reasonable. Pull the per-file work into its own function (or an inline closure) so the defer has a smaller scope to close over.
range Copies the Value
type Counter struct{ N int }
counters := []Counter{{N: 1}, {N: 2}}for _, c := range counters { c.N++}fmt.Println(counters) // unchanged: [{1} {2}]c is a copy of each element, so mutating it does nothing to the slice. This one is usually caught fast because the output is obviously wrong, but it’s still a common first reaction of “wait, why didn’t that update?” Index into the slice instead if you need to mutate in place:
for i := range counters { counters[i].N++}None of these are exotic. They’re all things the spec explains clearly if you go looking, which is exactly the problem — you only go looking after they’ve already cost you an hour.