Common Go Beginner Mistakes and How to Spot Them in Code
Thirteen common Go beginner mistakes, each with the code that shows it, the output you get and the fix, from shadowed errors to typed nil values.

Every language has its first-month mistakes, and Go's are unusually consistent. The compiler catches the obvious ones for you, which is part of why the language is easy to start. The ones that remain compile cleanly and then do something slightly different from what you meant, and they come from the same few places: a copy where you expected sharing, sharing where you expected a copy, and a zero value you did not initialize. This article collects thirteen of them. Each has the smallest program that shows it, the output it really produced on Go 1.25.6, and the fix. If you have just finished the basics path, read it with your own code open and look for these patterns.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
1. Unused variables and imports
Go refuses to compile a program with an unused local variable or an unused import. This is the first thing every newcomer runs into, and it is deliberate: dead code is a bug waiting to be noticed.
package main
import (
"fmt"
"os"
)
func main() {
x := 1
fmt.Println("hi")
}# example.test/lab
./main.go:5:2: "os" imported and not used
./main.go:9:2: declared and not used: xDelete the import and the variable, or use them. During exploration, assign to the blank identifier (_ = x) to silence the compiler temporarily, and remove it before committing. Editors that run gopls fix imports on save, which makes this problem disappear in practice. See declared and not used for the full story.
2. Shadowing a variable with :=
The short declaration := creates a new variable in the current scope, and inside an if or for block that is a new scope. Assigning to what you thought was the outer variable then does nothing to it:
package main
import (
"errors"
"fmt"
)
func work() (int, error) { return 0, errors.New("disk full") }
func main() {
var err error
if true {
n, err := work()
_ = n
_ = err
}
fmt.Println("outer err:", err)
}outer err: <nil>The outer err is still nil, so a function that continued this way would carry on as though nothing had failed. The fix is to declare only what is new: var n int before the block, then n, err = work() with plain =. The shadow analyzer from golang.org/x/tools finds these, and variable shadowing shows how to run it.
3. Writing to a nil map
Declaring a map variable without make gives you a nil map. Reading from it is fine and returns zero values. Writing to it panics:
package main
import "fmt"
func main() {
defer func() { fmt.Println("recovered:", recover()) }()
var counts map[string]int
fmt.Println(counts["a"], len(counts))
counts["a"]++
}0 0
recovered: assignment to entry in nil mapInitialize with make(map[string]int) or a literal. A map field in a struct is nil until you set it, so constructors for such structs should create the map. Assignment to entry in nil map covers the patterns.
4. Slices that share an array
Slicing does not copy. A sub-slice points at the same backing array, so an append that fits within the capacity writes into the original:
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4}
b := a[:2]
b = append(b, 99)
fmt.Println(a, b)
}[1 2 99 4] [1 2 99]The 99 overwrote the 3 in a. When you hand a slice to code that might append to it, either copy it first (slices.Clone) or cap the capacity with a full slice expression, a[:2:2], so an append is forced to reallocate. The mechanics are in slices explained and three-index slices.
5. Modifying the copy in a range loop
The variable in for _, v := range items holds a copy of each element. Changing a field of v changes the copy:
package main
import "fmt"
type Item struct{ Qty int }
func main() {
items := []Item{{1}, {2}}
for _, it := range items {
it.Qty *= 10
}
fmt.Println(items)
for i := range items {
items[i].Qty *= 10
}
fmt.Println(items)
}[{1} {2}]
[{10} {20}]Index into the slice, or keep a slice of pointers when mutation in place is the normal case. The same idea applies to arrays, which are copied when ranged over by value.
6. Capturing the loop variable
Before Go 1.22 every iteration of a for loop shared one variable, so closures and goroutines started in the loop all saw the final value. Go 1.22 gave each iteration its own variable, but only for modules whose go.mod says go 1.22 or later. A module that still says go 1.21 keeps the old behavior:
package main
import "fmt"
func main() {
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() { fmt.Print(i, " ") })
}
for _, f := range fns {
f()
}
fmt.Println()
}3 3 3This prints 3 3 3 under the older language version. Raise the go line to 1.22 and the same code changes its answer:
package main
import "fmt"
func main() {
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() { fmt.Print(i, " ") })
}
for _, f := range fns {
f()
}
fmt.Println()
}0 1 2Each closure now sees its own i. If you are reading old code, or maintaining a module whose go line was never bumped, you will see the workaround i := i on the first line of the loop body. The loop variable change has the details, and the Go team's own account is the Go 1.22 release notes.
7. Ignoring an error
strconv.Atoi returns the number and an error. Discarding the error with _ compiles and gives you a zero:
package main
import (
"fmt"
"strconv"
)
func main() {
n, _ := strconv.Atoi("12a")
fmt.Println(n)
_, err := strconv.Atoi("12a")
fmt.Println(err)
}0
strconv.Atoi: parsing "12a": invalid syntaxA zero that came from bad input is indistinguishable from a zero that was meant, which is how an invoice total ends up as nothing. Check every error, and let errcheck or golangci-lint catch the ones you forget (errcheck). If you truly mean to ignore one, say why in a comment.
8. Comparing errors with ==
An error that has been wrapped with %w is a different value from the one inside it. Comparing with == stops working as soon as anything adds context:
package main
import (
"errors"
"fmt"
"io"
)
func main() {
err := fmt.Errorf("read config: %w", io.EOF)
fmt.Println(err == io.EOF, errors.Is(err, io.EOF))
}false trueerrors.Is walks the chain, and errors.As finds a value of a given type in it. Make errors.Is your default, and treat == on an error as a code smell. Wrapping errors explains the chain.
9. Treating a string as characters
A Go string is a sequence of bytes, usually UTF-8. Indexing gives a byte and len counts bytes. Neither is a letter:
package main
import "fmt"
func main() {
s := "héllo"
fmt.Println(len(s), len([]rune(s)), s[1])
for i, r := range s {
fmt.Print(i, ":", string(r), " ")
}
fmt.Println()
}6 5 195
0:h 1:é 3:l 4:l 5:olen is 6 for five letters, s[1] is 195, the first byte of the two-byte é, and range decodes whole characters, called runes, with their byte offsets (h at 0, é at 1, then l at 3). Use []rune(s) or utf8.RuneCountInString when you mean characters. See strings and bytes and runes and bytes.
10. Integer division
Dividing two integers gives an integer, and converting afterwards does not recover the fraction:
package main
import "fmt"
func main() {
fmt.Println(7/2, float64(7/2), float64(7)/2)
}3 3 3.5Convert an operand before dividing. This is the Go form of a bug that exists in most typed languages, and it shows up in averages and percentages first.
11. A value receiver that cannot mutate
A method with a value receiver works on a copy of the struct. Changes to the fields are lost when it returns:
package main
import "fmt"
type Counter struct{ n int }
func (c Counter) IncWrong() { c.n++ }
func (c *Counter) Inc() { c.n++ }
func main() {
var c Counter
c.IncWrong()
fmt.Println(c.n)
c.Inc()
fmt.Println(c.n)
}0
1Use a pointer receiver for any method that changes the receiver, and for any type large enough that copying hurts. Be consistent: if one method needs a pointer receiver, give them all one. Pointer or value receivers works through the rules.
12. Forgetting that main does not wait
The program ends when main returns, whether or not other goroutines are done. A goroutine started with go f() and nothing to wait on may never get to run. The standard fix is a sync.WaitGroup:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 3)
for i := range results {
wg.Go(func() { results[i] = i * i })
}
wg.Wait()
fmt.Println(results)
}[0 1 4]Each goroutine writes its own slot, so there is no data race, and Wait guarantees all three have finished before the print. Without the Wait the print could run first and show zeros, or the program could exit before the goroutines were scheduled, and the outcome can change from run to run, which is what makes this mistake slow to find. WaitGroup and goroutines explained are the next reads.
13. A nil pointer inside an error
This one surprises experienced programmers too. An interface value holds a type and a value, and it is nil only when both are nil. A function that returns a nil *MyError as an error returns an interface with a type and no value, which is not nil:
package main
import "fmt"
type MyError struct{}
func (*MyError) Error() string { return "my error" }
func check(fail bool) error {
var e *MyError
if fail {
e = &MyError{}
}
return e
}
func main() {
err := check(false)
fmt.Println(err == nil, err != nil)
}false trueThe caller sees err != nil on a call that succeeded. Return the literal nil on the success path, and keep the concrete error type out of the variable that you return. The nil interface that is not nil explains why.
Catching these earlier
None of these needs cleverness to avoid, only habit. Run go vet on every change, since it catches several of the copy, format and lock-copying mistakes. Add staticcheck or golangci-lint for the checks vet leaves out, such as unchecked errors. Run tests with -race so that a forgotten wait or a shared slice shows up as a report and not as a bug in production (the race detector). Keep the language version in go.mod current, since several of the sharpest edges, the loop variable among them, were smoothed out in recent releases. And read other people's code: how to read Go code has a method, and idiomatic Go describes the conventions that make mistakes stand out.
When you want drills rather than a checklist, Go exercises that teach the language has problems that trip over several of these on purpose. The Go FAQ answers most of the "why does Go do this" questions that follow from them.


