Common Go Mistakes
Go’s simplicity is a double-edged sword: the language has few surprises once you know it, but that small feature set means a handful of specific gotchas account for a huge share of real-world bugs. Most of these mistakes are not typos the compiler catches for you — they compile cleanly and only misbehave at runtime, which is exactly why they trip up experienced programmers moving from other languages. This lesson catalogs the mistakes that show up over and over in Go codebases, explains precisely why each one happens, and shows the idiomatic fix.
Overview: why these mistakes keep happening
Go’s compiler is strict about some things and silent about others, and the gap between the two is where most bugs live. The compiler refuses to build a program with an unused import or an unused local variable, and it refuses to compare two slices with == at all — these are caught for you, every time, before the program ever runs. But the compiler has no opinion about whether you checked an error return value, whether a map was initialized with make before you wrote to it, or whether a variable declared with := inside an if block shadows one from the enclosing scope. Those are all valid Go, syntactically, and the bug only shows up when the wrong branch runs in production.
Two pieces of Go’s runtime design explain several of these mistakes directly. First, a map is implemented as a pointer to an internal hash-table structure (runtime.hmap). A nil map is a pointer with nothing behind it: reading from it is special-cased by the runtime to safely return the zero value, but writing to it requires an actual table to insert into, so the runtime panics instead of allocating one for you implicitly — Go wants map allocation to be an explicit, visible decision. Second, goroutines are cooperatively scheduled by the Go runtime onto a small pool of OS threads (the M:N scheduler), and a goroutine created inside a loop does not automatically get its own private snapshot of the loop’s control variables unless you give it one. Before Go 1.22, the loop variable was a single storage location reused on every iteration, so a closure that referenced it directly would usually see whatever value the loop had reached by the time the goroutine actually ran, not the value at the moment it was launched. Go 1.22 changed the language so each iteration gets its own variable, but the defensive habit of passing loop values in explicitly is still worth keeping, both for portability to code that must build with older Go versions and because it makes the intent obvious to readers.
Slices add a third wrinkle: a slice value is a small header of a pointer, a length, and a capacity, sitting on top of a shared underlying array. Two slices can silently alias the same backing array after a reslice operation, and appending past capacity allocates a brand-new array, which is why Go forbids comparing slices with == altogether (a shallow pointer comparison would be meaningless, and a deep comparison would be surprisingly expensive to do implicitly) and why append‘s result must always be reassigned rather than treated as an in-place mutation.
Quick Reference: Mistake Patterns
| Pattern | Symptom | Fix |
|---|---|---|
x, _ := f() |
A real failure is silently ignored | x, err := f(); if err != nil { ... } |
go func() { use(i) }() inside a loop |
Goroutines can observe an unexpected value of i |
Pass it in: go func(i int) { use(i) }(i) |
if a == b where a, b are slices |
Compile error: slice can not be compared to slice | slices.Equal(a, b) or reflect.DeepEqual(a, b) |
var m map[K]V; m[k] = v |
Panic: assignment to entry in nil map | m := make(map[K]V) before any write |
if x, err := f(); err == nil { ... } |
Inner x/err shadow the outer ones and never update them |
Use = instead of := when you mean to reuse the outer variables |
Examples
The examples below show the correct patterns in full, runnable programs; the Common Mistakes section further down pairs each one with the broken version it’s fixing.
Example 1: checking an error instead of discarding it
package main
import (
"fmt"
"strconv"
)
func main() {
input := "42"
n, err := strconv.Atoi(input)
if err != nil {
fmt.Println("conversion failed:", err)
return
}
fmt.Println("parsed value:", n)
}
Output:
parsed value: 42
strconv.Atoi returns two values: the parsed integer and an error. Checking err before touching n means a malformed input is reported clearly instead of silently becoming 0.
Example 2: nil maps are readable but not writable
package main
import "fmt"
func main() {
var counts map[string]int
fmt.Println("read from nil map:", counts["missing"])
counts = make(map[string]int)
counts["apples"] = 3
counts["apples"]++
fmt.Println("apples:", counts["apples"])
}
Output:
read from nil map: 0
apples: 4
counts starts as a nil map. Reading a missing key from it is perfectly safe and returns the zero value for the value type, 0 here. Once we call make, the map has real storage behind it and both writes and increments work as expected.
Example 3: passing loop values into goroutines explicitly
package main
import (
"fmt"
"sync"
)
func main() {
numbers := []int{2, 4, 6, 8}
results := make([]int, len(numbers))
var wg sync.WaitGroup
for i, n := range numbers {
wg.Add(1)
go func(i, n int) {
defer wg.Done()
results[i] = n * n
}(i, n)
}
wg.Wait()
fmt.Println(results)
}
Output:
[4 16 36 64]
Each goroutine receives its own copy of i and n as function parameters at the moment it is launched, rather than reading a shared loop variable later. Because every goroutine writes to a distinct index of results, there is no data race even though the goroutines finish in an unpredictable order — wg.Wait() just guarantees all of them are done before we print.
How it works step by step
Walking through Example 3 in detail: wg.Add(1) increments the WaitGroup‘s internal counter once per iteration, before the corresponding goroutine starts, which avoids a race between Add and Wait. The expression go func(i, n int) { ... }(i, n) does two things: it declares an anonymous function with its own local parameters named i and n, and it immediately calls that function with the current loop values as arguments, handing control to the Go runtime to schedule as a new goroutine. Because the arguments are evaluated and copied at the call site, each goroutine’s i and n are independent local variables from that point on — nothing later in the loop can change them.
Internally, the Go runtime multiplexes goroutines onto a limited number of OS threads (the GOMAXPROCS setting controls how many run in parallel). A goroutine that is ready to run sits in a scheduler queue until a thread is free to execute it; the order in which the four goroutines in Example 3 actually run their body is not guaranteed. What is guaranteed is that defer wg.Done() runs before that goroutine’s stack is discarded, and that wg.Wait() in main blocks until the counter drops back to zero, which only happens after every Done() has been called. Only then does execution reach fmt.Println(results), by which point every slot in results has been written exactly once.
Common Mistakes
Mistake 1: discarding an error return
Wrong:
data, _ := strconv.Atoi("not-a-number")
fmt.Println(data * 2)
Using _ for the error throws away the only signal that something went wrong. strconv.Atoi returns 0 alongside a non-nil error when parsing fails, so this prints 0 without ever revealing that the input was invalid.
Right:
package main
import (
"fmt"
"strconv"
)
func main() {
value := "not-a-number"
data, err := strconv.Atoi(value)
if err != nil {
fmt.Println("invalid number:", err)
return
}
fmt.Println(data * 2)
}
Mistake 2: writing to a nil map
Wrong:
var scores map[string]int
scores["alice"] = 10
scores is declared but never initialized, so it holds the zero value for a map, which is nil. Reading from a nil map is fine, but this line writes to one, which panics with assignment to entry in nil map.
Right:
package main
import "fmt"
func main() {
scores := make(map[string]int)
scores["alice"] = 10
fmt.Println(scores)
}
Mistake 3: comparing slices with ==
Wrong:
a := []int{1, 2, 3}
b := []int{1, 2, 3}
if a == b {
fmt.Println("equal")
}
This does not even compile. Go only allows == on a slice when comparing it to the literal nil; comparing two slices to each other is a compile-time error, because a shallow comparison of the underlying pointers would rarely be what you want.
Right:
package main
import (
"fmt"
"slices"
)
func main() {
a := []int{1, 2, 3}
b := []int{1, 2, 3}
if slices.Equal(a, b) {
fmt.Println("equal")
} else {
fmt.Println("not equal")
}
}
Mistake 4: shadowing with := inside an if
Wrong:
func getConfig() (string, error) {
config, err := loadFromFile()
if err != nil {
config, err := loadFromEnv()
if err != nil {
return "", err
}
fmt.Println("loaded from env")
}
return config, nil
}
The inner config, err := declares two brand-new variables scoped only to the if block, because := always creates a new variable when at least one name on the left is new in that scope — here both are treated as new since they’re being redeclared with := in a nested block. Even though loadFromEnv succeeds, the outer config is never touched, so the function returns an empty string.
Right:
package main
import (
"errors"
"fmt"
)
func loadFromEnv() (string, error) {
return "env-config", nil
}
func loadFromFile() (string, error) {
return "", errors.New("file not found")
}
func getConfig() (string, error) {
config, err := loadFromFile()
if err != nil {
config, err = loadFromEnv()
if err != nil {
return "", err
}
fmt.Println("loaded from env")
}
return config, err
}
func main() {
config, err := getConfig()
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("config:", config)
}
Output:
loaded from env
config: env-config
Using plain = instead of := inside the if reuses the outer config and err variables directly, so the value from loadFromEnv actually propagates out of the function.
Best Practices
- Check every
errorreturn immediately after the call that produces it; reserve_for errors you are deliberately teaching or testing around, never for routine code. - Run
go vet(and a linter such asgolangci-lint) as part of your normal workflow — vet catches several shadowing and formatting mistakes statically, before code review. - Always initialize maps with
makeor a map literal before writing to them; treat an uninitialized map field in a struct as a bug waiting to happen. - Pass loop values into goroutines and closures as explicit function parameters, even on Go 1.22+, so the code stays correct on older toolchains and the intent is obvious to readers.
- Prefer
slices.Equalormaps.Equal(both added to the standard library in Go 1.21) overreflect.DeepEqualwhen comparing slices or maps of comparable elements — they are faster and clearer. - When in doubt about shadowing, give the inner variable a different name, or use
=explicitly to signal that you mean to reuse the outer one. - Always reassign the result of
append(s = append(s, x)); never assume it mutates the slice in place.
Practice Exercises
- Write a function
Divide(a, b int) (int, error)that returns an error instead of panicking whenbis0, and amainthat calls it withb = 0and prints the error message. - Take a loop that launches one goroutine per element of a slice to compute its square, writing each result into a pre-sized results slice at the matching index. Use a
sync.WaitGroupsomainwaits for every goroutine to finish before printing the results, and pass the loop variables in as explicit parameters. - Given
var m map[string][]int, write code that safely appends10to the slice stored under key"a"without panicking, whether or not"a"already exists in the map. Hint: a nil slice can be appended to safely, but a nil map cannot be written to — you still needmakefor the map itself before the first write.
Summary
- The Go compiler catches unused imports/variables and slice-to-slice
==comparisons, but not logic bugs like shadowing or nil map writes — those only show up at runtime or via tools likego vet. - Never discard an
errorreturn with_in real code; check it immediately. - A nil map is safe to read from (returns the zero value) but panics if you write to it — always
makeit first. - Slices cannot be compared with
==; useslices.Equalorreflect.DeepEqualinstead. - Pass loop and range variables into goroutines and closures as explicit parameters to avoid capturing the wrong value, especially for code that must run on Go versions before 1.22.
- Watch for
:=insideif/forblocks silently shadowing an outer variable instead of updating it — use=when you mean to reuse the outer one. - Lean on
go vetand linters to catch many of these mistakes automatically before they reach production.
