Common Mistakes and Gotchas

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27 unless it is the deliberately old one in the loop-variable section. Go is a small language with few surprises, but the surprises it has are the same ones every team meets: a value that looks like nil and is not, a slice that shares memory it appears to own, a := that quietly creates a second variable, a goroutine nobody ever stops. This page collects them, one section each. Every section follows the same shape: the mistake in a small program from the shelf catalogue, what really happens (real output), the fix, and which tool reports it (a go vet analyzer, a go fix modernizer, the race detector or the runtime itself).

It is a map into the rest of the reference rather than a replacement for it: each mistake links to the page that explains the underlying rule. The relevant pages are Interfaces, Error Handling, Arrays and Slices, Maps, Strings, Runes and UTF-8, Control Flow, Functions, Closures and Defer, Goroutines and the Scheduler, Channels and Select, Sync and Atomics, Memory Model and Data Races, net/http Clients and Servers and Formatting, vet, fix and Static Analysis. The companion pages on this site for comparison are Rust ownership and moves (where several of these mistakes are compile errors) and C pointers. The Go project’s own lists are the wiki page Common Mistakes and the Go FAQ; this page expands both with runnable programs.

Every program was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output (compiler and vet messages are likewise copied from real runs). Absolute file paths in tracebacks are shortened to /work/shelf, and values that differ on every run (addresses, ports, timings) are real values from one run and say so. The example module is:

module example.com/shelf

go 1.27

The catalogue of mistakes at a glance

The table is the page in one screen. The right-hand column says what reports the mistake: go vet runs a default set of analyzers (see go tool vet help for the list); go test runs only a high-confidence subset of them (atomic, bools, buildtag, directive, errorsas, ifaceassert, nilfunc, printf, stdversion, stringintconv and tests), so the other analyzers need an explicit go vet ./... in CI. "Runtime" means the program crashes or panics instead of being caught earlier; "none" means no standard tool reports it and you need a test, a race run or a review.

Mistake Symptom Caught by

A nil pointer stored in an error result

err != nil is true although nothing failed

none in go vet; tests and review

append to a sub-slice with spare capacity

two slices overwrite each other’s elements

none; use a full slice expression

A small sub-slice of a huge slice kept alive

memory is not freed

none; slices.Clone, heap profile

Writing to a nil map

panic: assignment to entry in nil map

runtime panic

Relying on map iteration order

output order changes from run to run

none; sort the keys

Concurrent map writes

fatal error: concurrent map writes

the runtime and the race detector

Modifying a range value copy

the slice or array is unchanged

none

:= shadowing a variable or result

the outer variable keeps its old value

compiler for results; shadow analyzer (separate tool)

defer in a loop

descriptors or locks pile up until the function returns

none; move the body into a function

Deferred call arguments evaluated immediately

defer fmt.Println(time.Since(start)) prints about zero

defers analyzer for the time.Since form

Goroutine leak

goroutine count and memory grow

goroutineleak profile, runtime.NumGoroutine, tests

Unbuffered send with no receiver

fatal error: all goroutines are asleep - deadlock!

runtime (only when every goroutine is blocked)

wg.Add inside the goroutine

Wait returns early

waitgroup analyzer, race detector

Not closing Response.Body

connections leak, no reuse

httpresponse analyzer (for the use-before-check form)

Integer overflow, int division

silent wrap-around, truncation towards zero

compiler for constants; none at run time

string(n) of an integer

a single character, not digits

stringintconv analyzer (also in go test)

Indexing a string

a byte, not a character

none

Copying a sync.Mutex

the copy is a separate lock

copylocks analyzer

Comparing errors with ==

wrapped errors are not recognised

none; use errors.Is and errors.AsType

Loop variable captured by a closure

every goroutine sees the same value (modules older than go 1.22)

loopclosure analyzer; fixed by the language in go 1.22

Types and values

A nil pointer inside a non-nil interface

An interface value is a pair of a dynamic type and a dynamic value, and it is nil only when both are absent (see Interfaces, and the figure in Error Handling). A function that returns an error but declares its local variable with the concrete pointer type stores the type in the interface even when the pointer is nil:

package main

import "fmt"

// NotFoundError reports a missing book.
type NotFoundError struct{ ISBN string }

func (e *NotFoundError) Error() string { return "book not found: " + e.ISBN }

// lookupBad returns a typed nil pointer wrapped in a non-nil error.
func lookupBad(isbn string) error {
	var err *NotFoundError
	if isbn == "missing" {
		err = &NotFoundError{ISBN: isbn}
	}
	return err
}

// lookup returns the untyped nil interface on success.
func lookup(isbn string) error {
	if isbn == "missing" {
		return &NotFoundError{ISBN: isbn}
	}
	return nil
}

func main() {
	err := lookupBad("9780134190440")
	fmt.Println("bad:  err != nil:", err != nil, "dynamic type:", fmt.Sprintf("%T", err))

	err = lookup("9780134190440")
	fmt.Println("good: err != nil:", err != nil)
}
bad:  err != nil: true dynamic type: *main.NotFoundError
good: err != nil: false

The result is a non-nil error whose method set is that of *NotFoundError and whose value is a nil pointer. The caller’s if err != nil branch runs, and calling err.Error() would dereference the nil pointer inside the method.

The fix is to return the literal nil on success, and to keep the local variable of type error (or return directly from each branch) so a typed nil is never converted to the interface. There is no default go vet analyzer for this pattern, so it is found by tests that assert err == nil on the success path and by code review. The Go FAQ answers it as Why is my nil error value not equal to nil?; the same trap applies to any interface, not just error.

Integer overflow, truncating division and floating point

Integer arithmetic wraps silently at run time and is checked only for constants. A constant expression that does not fit its type is a compile error:

package main

import "fmt"

func main() {
	var b byte = 256
	x := 10 / 0
	var n int32 = 1 << 40
	fmt.Println(b, x, n)
}
# example.com/shelf/gotchas/intconst
gotchas/intconst/main.go:6:15: cannot use 256 (untyped int constant) as byte value in variable declaration (overflows)
gotchas/intconst/main.go:7:12: invalid operation: division by zero
gotchas/intconst/main.go:8:16: cannot use 1 << 40 (untyped int constant 1099511627776) as int32 value in variable declaration (overflows)

Variables are another matter: there is no overflow check, no trap and no flag to turn one on. Division truncates towards zero, and an integer division or remainder by a zero variable panics, while floating-point division by zero yields an infinity instead. Floating-point constants are exact at compile time but variables are not, which is why the same comparison differs between a constant expression and variables:

package main

import (
	"fmt"
	"math"
)

func main() {
	var small int8 = 127
	small++
	fmt.Println("int8 127+1:", small)

	var u uint = 0
	u--
	fmt.Println("uint 0-1:", u == math.MaxUint64)

	fmt.Println("-7 / 2 =", -7/2, " -7 % 2 =", -7%2)
	fmt.Println("1/2 * 10 =", 1/2*10, " 10 * 1/2 =", 10*1/2)

	mid := math.MaxInt64
	lo, hi := mid-1, mid
	fmt.Println("(lo+hi)/2 overflows:", (lo+hi)/2 < 0)
	fmt.Println("lo+(hi-lo)/2 is safe:", lo+(hi-lo)/2 > 0)

	f := 3.99
	fmt.Println("int(3.99) =", int(f), " int(-3.99) =", int(-f))
	fmt.Println("0.1+0.2 == 0.3:", 0.1+0.2 == 0.3)
	a, b := 0.1, 0.2
	fmt.Println("variables:", a+b == 0.3, a+b)

	d := 0
	defer func() { fmt.Println("recovered:", recover()) }()
	fmt.Println(10 / d)
}
int8 127+1: -128
uint 0-1: true
-7 / 2 = -3  -7 % 2 = -1
1/2 * 10 = 0  10 * 1/2 = 5
(lo+hi)/2 overflows: true
lo+(hi-lo)/2 is safe: true
int(3.99) = 3  int(-3.99) = -3
0.1+0.2 == 0.3: true
variables: false 0.30000000000000004
recovered: runtime error: integer divide by zero

Things worth remembering from this output:

  • int8 and the other sized integers wrap (127 + 1 is -128); uint wraps below zero to the maximum value. The midpoint formula (lo+hi)/2 overflows for large values, which is why lo+(hi-lo)/2 is the safe form (the classic binary-search bug).

  • -7 / 2 is -3 and -7 % 2 is -1: both truncate towards zero, so % takes the sign of the dividend.

  • 1/2*10 is 0 but 10*1/2 is 5: operators of equal precedence group from the left and the 1/2 constant is an integer division.

  • Converting a float to an integer truncates; converting a value that does not fit is implementation-defined in the specification, not a panic.

  • Never compare floating-point results with == in code that did arithmetic; compare with a tolerance.

When overflow matters (sizes read from a file, money, counters), check before the operation, use a wider type, or use math/bits (bits.Add64, bits.Mul64) which report the carry. See Types and Values and Operators and Expressions.

string(n) is not Itoa

Converting an integer to a string yields the string of that one Unicode code point, not its decimal digits:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n := 65
	fmt.Println(string(n), strconv.Itoa(n))
}
A 65

The stringintconv analyzer reports it, and because it is part of the high-confidence subset the report also stops go test:

gotchas/strint/main.go:10:14: conversion from int to string yields a string of one rune, not a string of digits

Use strconv.Itoa (or fmt.Sprint). If a code point really is wanted, convert a rune explicitly: string(rune(n)) passes the analyzer. The old book remark that go vet "blocks" this conversion is imprecise: it is a vet report, not a compile error.

Strings are bytes

Indexing a string returns a byte, slicing cuts at byte offsets, len counts bytes, and only range decodes runes:

package main

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

func main() {
	title := "Les Misérables"
	fmt.Println("len:", len(title), "runes:", utf8.RuneCountInString(title))
	fmt.Println("title[7]:", title[7], "type", fmt.Sprintf("%T", title[7]))
	fmt.Printf("title[:8] = %q (cuts the é in half)\n", title[:8])

	for i, r := range "Zoë" {
		fmt.Print(i, ":", string(r), " ")
	}
	fmt.Println()

	rs := []rune(title)
	fmt.Println("first 8 runes:", string(rs[:8]))
	fmt.Println(strings.ToUpper(title))

	var sb strings.Builder
	for i := range 3 {
		fmt.Fprintf(&sb, "%d;", i)
	}
	fmt.Println(sb.String())
}
len: 15 runes: 14
title[7]: 195 type uint8
title[:8] = "Les Mis\xc3" (cuts the é in half)
0:Z 1:o 2:ë
first 8 runes: Les Misé
LES MISÉRABLES
0;1;2;

title[7] is the first byte of the two-byte é (195, type uint8), and the slice title[:8] splits it, producing invalid UTF-8 that %q shows as the escape \xc3. Use utf8.RuneCountInString for the number of code points, for i, r := range s to walk runes (the index is a byte offset), []rune(s) when random access by code point is needed, and strings.Builder rather than repeated += to build text. A "character" the user sees may be several code points (an accent written as a combining mark), which neither bytes nor runes capture; the standard library has no grapheme segmentation, and golang.org/x/text is the usual answer. See Strings, Runes and UTF-8.

Slices and maps

append aliasing

A slice is a window onto an array. append writes into the existing array when there is spare capacity and allocates a new one only when there is not, so whether two results share memory depends on the capacity of the input:

package main

import "fmt"

func main() {
	base := make([]string, 3, 4)
	copy(base, []string{"Dune", "Emma", "Ulysses"})

	a := append(base, "Hamlet")
	b := append(base, "Walden")
	fmt.Println("a:", a, "b:", b)
	fmt.Println("len/cap of base:", len(base), cap(base))

	full := base[:3:3]
	c := append(full, "Hamlet")
	d := append(full, "Walden")
	fmt.Println("c:", c, "d:", d)

	var xs []int
	prev := -1
	for i := range 20 {
		xs = append(xs, i)
		if cap(xs) != prev {
			fmt.Print("cap=", cap(xs), " ")
			prev = cap(xs)
		}
	}
	fmt.Println()

	sub := []int{1, 2, 3, 4, 5}
	view := sub[1:3]
	view = append(view, 99)
	fmt.Println("sub:", sub, "view:", view)
}
a: [Dune Emma Ulysses Walden] b: [Dune Emma Ulysses Walden]
len/cap of base: 3 4
c: [Dune Emma Ulysses Hamlet] d: [Dune Emma Ulysses Walden]
cap=4 cap=8 cap=16 cap=32
sub: [1 2 3 99 5] view: [2 3 99]

a and b were both built from base, which has length 3 and capacity 4, so both appends wrote into the same fourth element and the second silently overwrote the first. The full slice expression base[:3:3] caps the capacity at the length, which forces append to copy, and c and d stay independent. The same effect hides in view = append(view, 99): the append overwrote element 3 of sub.

The rules to apply:

  • After append, use only the returned slice, and do not keep using the argument as if it were separate.

  • A function that receives a slice and appends to it can corrupt the caller’s data; either document that, or clip the capacity (slices.Clip(s) or s[:len(s):len(s)]) before handing a slice to code that may append.

  • Never rely on the growth pattern. The capacities printed above are an implementation detail; the growth formula changed in Go 1.18 (a smaller threshold with a smoother transition), and an older book that states a fixed doubling threshold of 1024 elements is out of date.

  • slices.Clone makes an independent copy; copy copies min(len(dst), len(src)) elements into an existing slice and allocates nothing.

No vet analyzer reports aliasing (the appends analyzer only catches append(s) with nothing appended). See Arrays and Slices for the slice header, growth and the full slice expression in depth.

A small slice that keeps a large array alive

A sub-slice points into the original array, so the garbage collector must keep the whole array while any slice into it is reachable. Keeping the first of eight one-mebibyte pages by reslicing keeps all eight:

package main

import (
	"fmt"
	"runtime"
	"slices"
)

type Page struct {
	ID   int
	Data [1 << 20]byte
}

func heapMB() uint64 {
	var m runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m)
	return m.HeapAlloc >> 20
}

func main() {
	pages := make([]*Page, 8)
	for i := range pages {
		pages[i] = &Page{ID: i}
	}
	fmt.Println("heap with 8 pages (MiB):", heapMB())

	first := pages[:1]
	pages = nil
	fmt.Println("after dropping all but a subslice (MiB):", heapMB())
	runtime.KeepAlive(first)

	pages = make([]*Page, 8)
	for i := range pages {
		pages[i] = &Page{ID: i}
	}
	first = slices.Clone(pages[:1])
	clear(pages)
	pages = nil
	fmt.Println("after Clone + clear (MiB):", heapMB())
	runtime.KeepAlive(first)
}
heap with 8 pages (MiB): 8
after dropping all but a subslice (MiB): 8
after Clone + clear (MiB): 1

After dropping the big slice and keeping only pages[:1], the heap is still 8 MiB, because that slice header still refers to the eight-element pointer array and so to every page behind it. slices.Clone of the part you need, followed by clear on the large slice (so its elements stop referencing the pages), lets the collector free the rest. The same applies to substrings: a small substring of a huge string keeps the huge string alive; strings.Clone copies it. The heap figures come from runtime.MemStats and will differ on other machines in detail but not in shape. See Memory Management and Garbage Collection.

Writing to a nil map

A nil map can be read, measured and ranged over, but not written to:

package main

import "fmt"

func main() {
	var stock map[string]int
	fmt.Println(stock["Dune"], len(stock))
	stock["Dune"]++
	fmt.Println("unreachable")
}
0 0
panic: assignment to entry in nil map

goroutine 1 [running]:
...

Initialise maps with make or a literal before writing. The zero value of a struct field of map type is also nil, so a constructor must make the map. A nil map is useful as a read-only empty value. See Maps.

Iteration order

The language deliberately gives a map no order, and the runtime randomises where iteration starts:

package main

import (
	"fmt"
	"maps"
	"slices"
	"sync"
)

func main() {
	stock := map[string]int{"Dune": 3, "Emma": 1, "Ulysses": 2, "Hamlet": 5}

	for run := range 3 {
		fmt.Print("run ", run, ":")
		for k := range stock {
			fmt.Print(" ", k)
		}
		fmt.Println()
	}

	for _, k := range slices.Sorted(maps.Keys(stock)) {
		fmt.Print(k, "=", stock[k], " ")
	}
	fmt.Println()

	var mu sync.Mutex
	var wg sync.WaitGroup
	for i := range 4 {
		wg.Go(func() {
			mu.Lock()
			defer mu.Unlock()
			stock[fmt.Sprint("title-", i)] = i
		})
	}
	wg.Wait()
	fmt.Println("entries:", len(stock))
}
run 0: Dune Emma Ulysses Hamlet
run 1: Hamlet Dune Emma Ulysses
run 2: Hamlet Dune Emma Ulysses
Dune=3 Emma=1 Hamlet=5 Ulysses=2
entries: 8

Three loops over the same unchanged map produced two different orders in this run (the first loop started at a different key); another run can show one, two or three different orders. Sort the keys when order matters: slices.Sorted(maps.Keys(m)) is the one-line form since Go 1.23. Do not write tests that compare against the text of a printed map; fmt sorts map keys when printing so that output is stable, but range does not. Adding entries during iteration may or may not make them appear.

The program also shows the safe way to write a map from several goroutines (one mutex), which leads to the next mistake.

Concurrent map access

A map is not safe for concurrent use when at least one goroutine writes. The runtime detects the common case and stops the program, which cannot be recovered from:

package main

import (
	"fmt"
	"sync"
)

func main() {
	loans := map[int]string{}
	var wg sync.WaitGroup
	for i := range 8 {
		wg.Go(func() {
			for j := range 10000 {
				loans[i*10000+j] = "Dune"
			}
		})
	}
	wg.Wait()
	fmt.Println(len(loans))
}
fatal error: concurrent map writes

goroutine 26 [running]:
internal/runtime/maps.fatal({0x10054f15b?, 0x0?})
...

The full traceback (omitted here) lists the goroutines involved. Under go run -race the same program is reported as a data race with both stacks first. Protect the map with a sync.Mutex or sync.RWMutex, confine it to one goroutine that owns it and communicates over a channel, or use sync.Map for the narrow cases it is designed for (keys written once and read many times, or goroutines working on disjoint keys); the FAQ explains why maps are not synchronised by default. Detection is best effort: a read racing a write may go unnoticed without -race. See Sync and Atomics and Memory Model and Data Races.

Range copies

range assigns each element to the iteration variable by value, so changing the variable changes the copy:

package main

import "fmt"

type Copy struct {
	Title string
	Loans int
}

func main() {
	copies := []Copy{{"Dune", 0}, {"Emma", 0}}

	for _, c := range copies {
		c.Loans++
	}
	fmt.Println("after value loop:", copies)

	for i := range copies {
		copies[i].Loans++
	}
	fmt.Println("after index loop:", copies)

	arr := [3]int{1, 2, 3}
	for i, v := range arr {
		arr[2] = 100
		if i == 2 {
			fmt.Println("array range saw", v)
		}
	}
	sl := []int{1, 2, 3}
	for i, v := range sl {
		sl[2] = 100
		if i == 2 {
			fmt.Println("slice range saw", v)
		}
	}

	ptrs := []*Copy{{"Hamlet", 0}}
	for _, p := range ptrs {
		p.Loans++
	}
	fmt.Println("pointer elements:", *ptrs[0])
}
after value loop: [{Dune 0} {Emma 0}]
after index loop: [{Dune 1} {Emma 1}]
array range saw 3
slice range saw 100
pointer elements: {Hamlet 1}
  • for _, c := range copies { c.Loans++ } changed nothing; use the index (copies[i].Loans++) or iterate over a slice of pointers.

  • Ranging over an array iterates over a copy of the array, so the assignment arr[2] = 100 was not seen (array range saw 3), while ranging over a slice sees the change (slice range saw 100). Ranging over a pointer to an array, or only the index, avoids the copy.

  • Pointer elements share the pointee, so p.Loans++ works.

There is no standard analyzer for the ineffective modification. Large element types are also copied each iteration, which is a performance rather than a correctness concern. See Control Flow and Pointers and Value Semantics.

Scope and control flow

Shadowing with :=

:= declares every variable on its left that is new in the current scope, and inside an if, for or switch block that means a fresh variable even if the name exists outside:

package main

import (
	"fmt"
	"os"
)

func loadBad(path string) ([]byte, error) {
	var data []byte
	var err error
	if path != "" {
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, err
		}
		fmt.Println("read", len(data), "bytes inside the if")
	}
	return data, err
}

func load(path string) ([]byte, error) {
	var data []byte
	if path != "" {
		var err error
		data, err = os.ReadFile(path)
		if err != nil {
			return nil, err
		}
		fmt.Println("read", len(data), "bytes inside the if")
	}
	return data, nil
}

func main() {
	f, _ := os.CreateTemp("", "shelf")
	defer os.Remove(f.Name())
	f.WriteString("Dune\n")
	f.Close()

	data, err := loadBad(f.Name())
	fmt.Println("loadBad returned", len(data), "bytes, err =", err)
	data, err = load(f.Name())
	fmt.Println("load returned", len(data), "bytes, err =", err)
}
read 5 bytes inside the if
loadBad returned 0 bytes, err = <nil>
read 5 bytes inside the if
load returned 5 bytes, err = <nil>

loadBad read five bytes but returned none: the data, err := inside the if created two new variables that died at the closing brace, and the outer data stayed nil. Declaring err once and assigning with = inside the block (as load does) fixes it. Neither the compiler nor the default go vet complains, because the program is legal.

The shadow analyzer in golang.org/x/tools/go/analysis/passes/shadow finds it, but it is not in go vet by default (it is noisy by design); run it as a separate vettool or through gopls and a linter configuration.

A named result that is shadowed and then returned with a bare return is the one form the compiler rejects:

package main

import (
	"errors"
	"fmt"
)

func open(fail bool) (err error) {
	if fail {
		err := errors.New("open failed")
		return
	}
	return nil
}

func main() {
	fmt.Println(open(true))
}
# example.com/shelf/gotchas/shadowret
gotchas/shadowret/main.go:10:3: declared and not used: err
gotchas/shadowret/main.go:11:3: result parameter err not in scope at return
	gotchas/shadowret/main.go:10:3: inner declaration of var err error

The compiler reports both the unused inner variable and the shadowed result. Avoid bare returns in all but the shortest functions; naming results is for documentation and for defer that modifies them. See Control Flow and Variables, Constants and iota, and the scopes figure in Program Structure and Lexical Elements.

Loop variables in modules older than go 1.22

Before Go 1.22 a for loop declared one variable that every iteration reused, so a closure or goroutine that captured it saw the value from the end of the loop. Go 1.22 changed the language so that each iteration has its own copy, but the change is tied to the go line of the module that contains the loop, so older modules keep the old behaviour. This program uses the same loop in two modules that differ only in that line.

The old module’s go.mod:

module example.com/legacy

go 1.21
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	results := make([]string, 3)
	for i, title := range []string{"Dune", "Emma", "Ulysses"} {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = title
		}()
	}
	wg.Wait()
	fmt.Println(results)
}

With go 1.21 (the loop variables are shared; the output can vary between runs, as it does here with a data race):

[  Ulysses]

With the line changed to go 1.22 the same source gives:

[Dune Emma Ulysses]

The loopclosure analyzer reports the old module (and stays silent for go 1.22 and later, where the pattern is correct):

loopvar/main.go:15:12: loop variable i captured by func literal
loopvar/main.go:15:17: loop variable title captured by func literal

So a snippet in an older tutorial that copies the loop variable into a new local first is not wrong, only redundant in a current module. The forvar modernizer of go fix removes such redundant copies once the module is at go 1.22 or later (see the modernizer section below). Per-iteration semantics apply to three-clause for loops as well as range loops, and only to variables declared by the loop itself. Do not raise the go line just to fix this without reading the other behaviour changes it brings; see Toolchain Versions and Compatibility and the figure in Control Flow.

defer in a loop

defer runs when the surrounding function returns, not at the end of the loop body. A loop that opens a file each iteration and defers the close holds every file open until the loop is finished. The next program lowers the process limit on open files to 64 (Go raises the soft limit at start-up, so the demo has to lower it) and tries 200 files:

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"syscall"
)

func totalBad(paths []string) (int, error) {
	total := 0
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return 0, err
		}
		defer f.Close() // runs when totalBad returns, not at the end of each iteration
		fi, err := f.Stat()
		if err != nil {
			return 0, err
		}
		total += int(fi.Size())
	}
	return total, nil
}

func size(p string) (int, error) {
	f, err := os.Open(p)
	if err != nil {
		return 0, err
	}
	defer f.Close()
	fi, err := f.Stat()
	if err != nil {
		return 0, err
	}
	return int(fi.Size()), nil
}

func total(paths []string) (int, error) {
	sum := 0
	for _, p := range paths {
		n, err := size(p)
		if err != nil {
			return 0, err
		}
		sum += n
	}
	return sum, nil
}

func main() {
	// Go raises the soft descriptor limit at start-up; lower it so the demo is small.
	lim := syscall.Rlimit{Cur: 64, Max: 64}
	if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
		fmt.Println(err)
		return
	}
	dir, err := os.MkdirTemp("", "shelf")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer os.RemoveAll(dir)
	var paths []string
	for i := range 200 {
		p := filepath.Join(dir, fmt.Sprint("f", i))
		if err := os.WriteFile(p, []byte("abcd"), 0o600); err != nil {
			fmt.Println(err)
			return
		}
		paths = append(paths, p)
	}
	n, err := totalBad(paths)
	fmt.Println("totalBad:", n, err != nil)
	if err != nil {
		fmt.Println(" error mentions:", err.(*os.PathError).Err)
	}
	fmt.Println(total(paths))
}
totalBad: 0 true
 error mentions: too many open files
800 <nil>

totalBad failed with too many open files because its deferred Close calls had not run yet. Moving the body into a function, so that the defer belongs to that function, is the fix, and is the usual shape (size above). The same applies to unlocking a mutex or cancelling a context in a loop. No analyzer reports it (some community linters do). See Functions, Closures and Defer.

Deferred arguments are evaluated immediately

The function value and the arguments of a deferred call are evaluated when the defer statement executes; only the call itself is postponed. This is the most common timing bug:

package main

import (
	"fmt"
	"time"
)

func slow() {
	start := time.Now()
	defer fmt.Println("took", time.Since(start))
	time.Sleep(20 * time.Millisecond)
}

func main() { slow() }
took 19.417µs

The "took" time is that of the instant between two statements (the value is machine-specific; the point is that it is microseconds, not 20 ms) because time.Since(start) was evaluated at the defer. go vet catches this exact shape with its defers analyzer:

gotchas/deferbad/main.go:10:28: call to time.Since is not deferred

Wrap the call in a closure, defer func() { ... time.Since(start) ... }(), or use a helper that returns the function to defer, as in the next program. It also shows the rest of the evaluation rules: a closure sees later changes to a captured variable, a plain deferred call keeps the value it had, deferred calls run last in, first out, and a deferred recover can set a named result:

package main

import (
	"fmt"
	"time"
)

func trace(name string) func() {
	start := time.Now()
	fmt.Println("enter", name)
	return func() { fmt.Println("leave", name, time.Since(start) > 0) }
}

func work() (result int, err error) {
	defer trace("work")()

	x := 1
	defer fmt.Println("deferred sees x =", x)
	defer func() { fmt.Println("closure sees x =", x) }()
	x = 2

	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	var m map[string]int
	m["k"] = 1
	return 42, nil
}

func main() {
	fmt.Println(work())
}
enter work
closure sees x = 2
deferred sees x = 1
leave work true
0 recovered: assignment to entry in nil map

The closure printed x = 2 and the argument form printed x = 1; the order is last in, first out; the recovered panic (a write to a nil map) became the error result, while the result 0 was never overwritten by the return 42, nil that never ran. See Functions, Closures and Defer.

Concurrency

Goroutine leaks

A goroutine that blocks forever is never reclaimed: the runtime does not stop it, and the garbage collector cannot free anything it references. The usual cause is a send or receive with no counterpart, for example an unbuffered result channel when only the first of several senders is ever read:

package main

import (
	"context"
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
	"time"
)

// firstBad returns the first answer from n mirrors.
func firstBad(n int) string {
	ch := make(chan string) // unbuffered: the losers block forever
	for i := range n {
		go func() { ch <- fmt.Sprint("mirror-", i) }()
	}
	return <-ch
}

// first does the same without leaking.
func first(ctx context.Context, n int) string {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	ch := make(chan string, n) // buffered: every sender can finish
	for i := range n {
		go func() {
			select {
			case ch <- fmt.Sprint("mirror-", i):
			case <-ctx.Done():
			}
		}()
	}
	return <-ch
}

func settle() int {
	time.Sleep(50 * time.Millisecond)
	return runtime.NumGoroutine()
}

func main() {
	fmt.Println("start:             ", runtime.NumGoroutine())
	for range 10 {
		first(context.Background(), 5)
	}
	fmt.Println("after first x10:   ", settle())
	for range 10 {
		firstBad(5)
	}
	fmt.Println("after firstBad x10:", settle())

	p := pprof.Lookup("goroutineleak")
	p.WriteTo(os.Stdout, 1) // runs a leak-detecting GC first
}
start:              1
after first x10:    1
after firstBad x10: 41
goroutineleak profile: total 40
40 @ 0x1022019b8 0x10219a854 0x10219a468 0x102257480 0x102207b04
#	0x10225747f	main.firstBad.func1+0x6f	/work/shelf/gotchas/leak/main.go:16
...

first (buffered channel, and a context that releases the losers) leaves nothing behind: the count stays at 1, the main goroutine. firstBad strands four goroutines per call (forty after ten calls, plus the main goroutine). The goroutineleak profile, generally available in Go 1.27 after an experimental phase, reports the goroutines that are blocked on a primitive that no other reachable goroutine can use, with the line where each is stuck. The addresses in the output are specific to this run. In a service the same information is available from /debug/pprof/goroutineleak once net/http/pprof is imported. In tests, compare runtime.NumGoroutine before and after, or run the code under testing/synctest, which fails a bubble that cannot make progress.

Rules that prevent leaks:

  • Every goroutine needs a known way to finish: a context.Context it watches, a channel it ranges over that someone closes, or work that is bounded.

  • Size a result channel to the number of senders, or make each send a select against ctx.Done().

  • The sender, not the receiver, closes a channel, and only once.

  • Start goroutines with sync.WaitGroup.Go (Go 1.25) or errgroup so that something waits for them.

Deadlock on an unbuffered channel

A send on an unbuffered channel blocks until a receiver is ready, so a goroutine cannot send to itself:

package main

import "fmt"

func main() {
	ch := make(chan string)
	ch <- "Dune" // no receiver and no other goroutine: blocks forever
	fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/work/shelf/gotchas/deadlock/main.go:7 +0x38
exit status 2
...

When every goroutine is blocked the runtime detects the deadlock and exits with status 2. That detection is limited: if any other goroutine is runnable, sleeping on a timer, waiting on the network or in a system call, nothing is reported and the program simply hangs. A deadlock inside a server is therefore silent, which is one more reason to collect goroutine profiles (/debug/pprof/goroutine?debug=2). Fix it by starting the receiver in another goroutine first, or by giving the channel a buffer that matches a deliberate design. See Channels and Select.

WaitGroup.Add inside the goroutine

Wait can return before a goroutine has even started if the Add is inside the goroutine:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	total := 0
	var mu sync.Mutex
	for i := range 3 {
		go func() {
			wg.Add(1)
			defer wg.Done()
			mu.Lock()
			total += i
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println(total)
}
0

The total should have been 3 (0 + 1 + 2) and was 0 here: Wait ran while the counter was still zero (the result varies from run to run). The waitgroup analyzer reports it:

gotchas/wgadd/main.go:14:10: WaitGroup.Add called from inside new goroutine

and the race detector reports the unsynchronised access to total (first lines):

0
==================
WARNING: DATA RACE
Write at 0x00c000186040 by goroutine 7:
  main.main.func1()
      /work/shelf/gotchas/wgadd/main.go:17 +0x9c

Previous read at 0x00c000186040 by main goroutine:
  main.main()
      /work/shelf/gotchas/wgadd/main.go:22 +0x194

Goroutine 7 (running) created at:
  main.main()
      /work/shelf/gotchas/wgadd/main.go:13 +0x9c
==================
Found 1 data race(s)

Call Add before the go statement, or let WaitGroup.Go do both: wg.Go(func() { ... }) adds, starts the goroutine and calls Done when the function returns (Go 1.25). The waitgroupgo modernizer rewrites the old three-statement form. See Sync and Atomics.

Copying a sync.Mutex

A mutex must not be copied after first use: the copy has its own state. A value receiver or an assignment copies the whole struct, lock included:

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c Counter) Inc() { // value receiver copies the mutex
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c Counter) Value() int { return c.n }

func main() {
	var c Counter
	c.Inc()
	fmt.Println(c.Value())
	d := c
	fmt.Println(d.Value())
}
0
0

Source: sync.Mutex

Inc locked and incremented a copy, so the counter stayed 0 with no error at all. The copylocks analyzer finds all three places:

gotchas/mutexcopy/main.go:13:9: Inc passes lock by value: example.com/shelf/gotchas/mutexcopy.Counter contains sync.Mutex
gotchas/mutexcopy/main.go:19:9: Value passes lock by value: example.com/shelf/gotchas/mutexcopy.Counter contains sync.Mutex
gotchas/mutexcopy/main.go:25:7: assignment copies lock value to d: example.com/shelf/gotchas/mutexcopy.Counter contains sync.Mutex

Give the type pointer receivers, return and pass pointers, and construct it once:

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

func main() {
	c := &Counter{}
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(c.Inc)
	}
	wg.Wait()
	fmt.Println(c.Value())
}
100

The same rule covers sync.WaitGroup, sync.Once, sync.Cond, sync.Pool and the typed values in sync/atomic (each contains a noCopy marker for the analyzer). Mixing value and pointer receivers on one type is also discouraged for this reason; see Methods and Receivers.

I/O and errors

Forgetting to close Response.Body

An http.Client call that returns without an error always returns a non-nil body that the caller must close, even when the status is an error and the body is ignored; until then the connection cannot be reused. The most common mistake is deferring the Close before checking err, because on failure resp is nil:

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func fetchBad(url string) (string, error) {
	resp, err := http.Get(url)
	defer resp.Body.Close()
	if err != nil {
		return "", err
	}
	b, err := io.ReadAll(resp.Body)
	return string(b), err
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "shelf")
	}))
	defer srv.Close()
	fmt.Println(fetchBad(srv.URL))
}
shelf <nil>

Source: net/http: Get

It happened to work here because the request succeeded, and go vet knows it is wrong with its httpresponse analyzer:

gotchas/body/main.go:12:8: using resp before checking for errors

The corrected helper checks the error first, closes the body, checks the status (a 404 is not a Go error), limits how much it reads and uses a client with a timeout:

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"time"
)

var client = &http.Client{Timeout: 5 * time.Second}

func fetch(url string) (string, error) {
	resp, err := client.Get(url)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("GET %s: %s", url, resp.Status)
	}
	b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	return string(b), err
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/missing" {
			http.NotFound(w, r)
			return
		}
		fmt.Fprint(w, "shelf")
	}))
	defer srv.Close()
	fmt.Println(fetch(srv.URL))
	fmt.Println(fetch(srv.URL + "/missing"))
	_, err := fetch("http://127.0.0.1:1/")
	fmt.Println("refused:", err != nil)
}
shelf <nil>
 GET http://127.0.0.1:60247/missing: 404 Not Found
refused: true

(The port in the message is a real one from this run.) Three further rules: http.Get and the http.DefaultClient have no timeout, so a stalled server blocks forever; read and discard what you do not need (io.Copy(io.Discard, resp.Body)) before closing if you want the connection reused; and server handlers do not close the request body themselves but should bound it with http.MaxBytesReader. See net/http Clients and Servers.

Comparing errors with ==

== compares an error with a sentinel only when it is the same value. Any code that adds context with %w breaks the equality, so use errors.Is for a specific value and errors.AsType (Go 1.26, or errors.As before) for a type:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

type Err struct{ Code int }

func (e *Err) Error() string { return fmt.Sprint("code ", e.Code) }

var ErrNotFound = errors.New("not found")

func find() error { return fmt.Errorf("find 978-0: %w", ErrNotFound) }

func main() {
	err := find()
	fmt.Println("== :", err == ErrNotFound)
	fmt.Println("Is :", errors.Is(err, ErrNotFound))

	_, err = os.Open("/no/such/file")
	fmt.Println("== fs.ErrNotExist:", err == fs.ErrNotExist)
	fmt.Println("Is fs.ErrNotExist:", errors.Is(err, fs.ErrNotExist))

	var e error = fmt.Errorf("wrap: %w", &Err{Code: 7})
	if _, ok := e.(*Err); !ok {
		fmt.Println("type assertion misses the wrapped *Err")
	}
	if got, ok := errors.AsType[*Err](e); ok {
		fmt.Println("AsType finds code", got.Code)
	}
	fmt.Println(errors.Is(nil, nil), errors.Is(err, nil))
}
== : false
Is : true
== fs.ErrNotExist: false
Is fs.ErrNotExist: true
type assertion misses the wrapped *Err
AsType finds code 7
true false

The direct comparison and the type assertion both missed the wrapped error, while errors.Is and errors.AsType walked the chain, including the *fs.PathError that wraps fs.ErrNotExist from the operating system. errors.Is(nil, nil) is true, and errors.Is(err, nil) is false for a non-nil error. The errorsas analyzer (part of go test's vet subset) reports errors.As with a second argument that is not a non-nil pointer to a type implementing error, and the errorsastype modernizer of go fix rewrites the pointer form into errors.AsType. A book that compares errors with == only was written before Go 1.13. See Error Handling.

Mistakes that vet reports in one pass

Several mistakes are one-liners. A single file with nine of them gives the whole picture of what go vet says, with the analyzer for each:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"time"
)

type Book struct {
	Title string `json:"title"`
	Pages int    `json:"pages" xml:"pages"`
	Year  int    "json:year"
}

func main() {
	ctx, _ := context.WithTimeout(context.Background(), time.Second)
	_ = ctx

	fmt.Printf("%d books on %s\n", "three")
	fmt.Println("done\n")
	fmt.Println(time.Now().Format("2006-02-01"))

	var b Book
	_ = json.Unmarshal([]byte(`{"title":"Dune"}`), b)

	fmt.Sprintf("%d books", 3)

	x := 1
	if x == 1 || x == 1 {
		fmt.Println("same operand twice")
	}
	sh := 1
	_ = sh << 70
}

go vet reports the following, sorted here by line (vet prints analyzers in no fixed order; the analyzer name is in the right-hand column):

Analyzer Report

structtag

gotchas/more/main.go:13:2: struct field tag `json:year` not compatible with reflect.StructTag.Get: bad syntax for struct tag value

lostcancel

gotchas/more/main.go:17:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

printf

gotchas/more/main.go:20:14: fmt.Printf format %d has arg "three" of wrong type string

printf

gotchas/more/main.go:21:2: fmt.Println arg list ends with redundant newline

timeformat

gotchas/more/main.go:22:33: 2006-02-01 should be 2006-01-02

unmarshal

gotchas/more/main.go:25:20: call of Unmarshal passes non-pointer as second argument

unusedresult

gotchas/more/main.go:27:2: result of fmt.Sprintf call not used

bools

gotchas/more/main.go:30:5: redundant or: x == 1 || x == 1

shift

gotchas/more/main.go:34:6: sh (64 bits) too small for shift of 70

The programs compile; only the analyzers object. The full output is:

gotchas/more/main.go:20:14: fmt.Printf format %d has arg "three" of wrong type string
gotchas/more/main.go:21:2: fmt.Println arg list ends with redundant newline
gotchas/more/main.go:34:6: sh (64 bits) too small for shift of 70
gotchas/more/main.go:13:2: struct field tag `json:year` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
gotchas/more/main.go:22:33: 2006-02-01 should be 2006-01-02
gotchas/more/main.go:25:20: call of Unmarshal passes non-pointer as second argument
gotchas/more/main.go:27:2: result of fmt.Sprintf call not used
gotchas/more/main.go:30:5: redundant or: x == 1 || x == 1
gotchas/more/main.go:17:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

In CI run go vet ./... (not only go test, whose vet subset omits lostcancel, structtag, shift, timeformat, unmarshal and unusedresult). Two of the reports concern behaviour that otherwise fails silently: the layout 2006-02-01 formats the month and day the wrong way round, and a context created by WithTimeout whose cancel function is dropped keeps its timer and its registration in the parent until the deadline. See Formatting, vet, fix and Static Analysis, Time and Timers and encoding/json.

Source: cmd/vet and fmt

Modernizers: fixing old patterns mechanically

go fix runs a set of analyzers that suggest rewrites to current idioms, rather than reporting defects. Its registry (go tool fix help) includes several that remove patterns from this page: forvar (redundant per-iteration copies), waitgroupgo (Add/go/Done into wg.Go), errorsastype (errors.As into errors.AsType), rangeint, minmax, any, stringsbuilder and testingcontext. This old-style file:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for _, t := range []string{"Dune", "Emma"} {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(t)
		}()
	}
	wg.Wait()

	_, err := os.Open("/no/such")
	var pe *fs.PathError
	if errors.As(err, &pe) {
		fmt.Println(pe.Path)
	}
}

gives this diff from go fix -diff (nothing is written to disk with -diff; drop the flag to apply):

@@ -11,17 +11,14 @@
 func main() {
 	var wg sync.WaitGroup
 	for _, t := range []string{"Dune", "Emma"} {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			fmt.Println(t)
-		}()
+		})
 	}
 	wg.Wait()

 	_, err := os.Open("/no/such")
-	var pe *fs.PathError
-	if errors.As(err, &pe) {
+	if pe, ok := errors.AsType[*fs.PathError](err); ok {
 		fmt.Println(pe.Path)
 	}
 }

Run it on a clean working tree and review the result like any change. Some rewrites depend on the module’s go line: the loop-variable cleanup is valid only under the per-iteration semantics of go 1.22 and later, and wg.Go needs go 1.25, so go fix offers them only when the line allows. See Formatting, vet, fix and Static Analysis and What’s New and Migrating Older Code.

Where the books are outdated

Older Go books and tutorials describe several of these pitfalls as they were; the current state is:

Older statement Current behaviour

Every closure and goroutine in a loop shares one loop variable, so copy it first

Per-iteration variables since Go 1.22 for modules whose go line is 1.22 or later; the copy is redundant and go fix removes it.

Compare errors with == against sentinels

%w, errors.Is, errors.As (Go 1.13), errors.Join (1.20) and errors.AsType (1.26).

panic(nil) recovers as nil

It produces a *runtime.PanicNilError since Go 1.21 (GODEBUG=panicnil=1 restores the old behaviour).

A slice doubles until 1024 elements, then grows by a quarter

Threshold 256 and a smoother formula since Go 1.18; the exact capacities are not part of any guarantee.

The scheduler is cooperative, so a tight loop can starve others

Asynchronous preemption since Go 1.14.

go vet "blocks" string(int)

It is a vet report (stringintconv), not a compile error, and it also runs under go test.

Time-based loops with time.After leak timers

Timers and tickers are garbage-collected when unreferenced and timer channels are synchronous since Go 1.23.

Start a worker with wg.Add(1), then go func() { defer wg.Done() ... }()

sync.WaitGroup.Go since Go 1.25 does all three steps; the waitgroupgo modernizer rewrites the old form.

The deprecated-API and tooling replacements (the old file helpers, the seeded random generator, build tags, the old linter, benchmark loops) are collected, with versions, in What’s New and Migrating Older Code.

A review checklist

Use these questions when reading Go code, in order of how often they find a defect:

  1. Is every error checked, and is every Close on a writer checked? Does a function return a typed pointer through an interface result?

  2. Does a goroutine have a way to finish? Who closes or drains each channel? Is every Add before its go?

  3. Is a map or slice written by more than one goroutine without a lock? Is the race detector (go test -race) in CI?

  4. Does a defer sit in a loop, or evaluate an argument that should be evaluated later?

  5. Is a := inside a block shadowing an outer err or a result?

  6. Could an append share memory with another slice, or does a small slice retain a large array?

  7. Do integer conversions and arithmetic on sizes, indexes and durations stay in range?

  8. Does every HTTP client have a timeout, a closed body and a status check? Does every server have timeouts?

  9. Is go vet ./... (not only go test) in CI, and is the go line current enough for the language fixes?

Version notes

Release Change relevant to this page

Go 1.14

Asynchronous preemption: tight loops no longer starve other goroutines.

Go 1.18

Smoother slice growth formula with a lower threshold.

Go 1.21

panic(nil) becomes *runtime.PanicNilError.

Go 1.22

Per-iteration loop variables for modules with go 1.22 or later; range over integers.

Go 1.23

Timers and tickers collectable and unbuffered timer channels; maps.Keys and slices.Sorted.

Go 1.25

sync.WaitGroup.Go and the waitgroup analyzer.

Go 1.26

errors.AsType; the go fix modernizers (forvar, waitgroupgo and errorsastype among them).

Go 1.27

The goroutineleak profile is generally available (experimental before); no change to the language rules on this page.