Skip to content
gohacks.dev
Basics

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.

8 min read
Summarize in AI
Flat illustration of an editor window full of code.

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.

main.goGo
package main

import (
	"fmt"
	"os"
)

func main() {
	x := 1
	fmt.Println("hi")
}
Text
# example.test/lab
./main.go:5:2: "os" imported and not used
./main.go:9:2: declared and not used: x

Delete 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:

main.goGo
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)
}
Text
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:

main.goGo
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"]++
}
Text
0 0
recovered: assignment to entry in nil map

Initialize 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:

main.goGo
package main

import "fmt"

func main() {
	a := []int{1, 2, 3, 4}
	b := a[:2]
	b = append(b, 99)
	fmt.Println(a, b)
}
Text
[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:

main.goGo
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)
}
Text
[{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:

main.goGo
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()
}
Text
3 3 3

This prints 3 3 3 under the older language version. Raise the go line to 1.22 and the same code changes its answer:

main.goGo
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()
}
Text
0 1 2

Each 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:

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, _ := strconv.Atoi("12a")
	fmt.Println(n)
	_, err := strconv.Atoi("12a")
	fmt.Println(err)
}
Text
0
strconv.Atoi: parsing "12a": invalid syntax

A 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:

main.goGo
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))
}
Text
false true

errors.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:

main.goGo
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()
}
Text
6 5 195
0:h 1:é 3:l 4:l 5:o

len 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:

main.goGo
package main

import "fmt"

func main() {
	fmt.Println(7/2, float64(7/2), float64(7)/2)
}
Text
3 3 3.5

Convert 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:

main.goGo
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)
}
Text
0
1

Use 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:

main.goGo
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)
}
Text
[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:

main.goGo
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)
}
Text
false true

The 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.