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 |
|
none in |
|
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; |
Writing to a nil map |
|
runtime panic |
Relying on map iteration order |
output order changes from run to run |
none; sort the keys |
Concurrent map writes |
|
the runtime and the race detector |
Modifying a range value copy |
the slice or array is unchanged |
none |
|
the outer variable keeps its old value |
compiler for results; |
|
descriptors or locks pile up until the function returns |
none; move the body into a function |
Deferred call arguments evaluated immediately |
|
|
Goroutine leak |
goroutine count and memory grow |
|
Unbuffered send with no receiver |
|
runtime (only when every goroutine is blocked) |
|
|
|
Not closing |
connections leak, no reuse |
|
Integer overflow, |
silent wrap-around, truncation towards zero |
compiler for constants; none at run time |
|
a single character, not digits |
|
Indexing a string |
a byte, not a character |
none |
Copying a |
the copy is a separate lock |
|
Comparing errors with |
wrapped errors are not recognised |
none; use |
Loop variable captured by a closure |
every goroutine sees the same value (modules older than |
|
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.
Source: Go FAQ: nil error and Specification: Comparison operators
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:
-
int8and the other sized integers wrap (127 + 1is-128);uintwraps below zero to the maximum value. The midpoint formula(lo+hi)/2overflows for large values, which is whylo+(hi-lo)/2is the safe form (the classic binary-search bug). -
-7 / 2is-3and-7 % 2is-1: both truncate towards zero, so%takes the sign of the dividend. -
1/2*10is0but10*1/2is5: operators of equal precedence group from the left and the1/2constant 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.
Source: stringintconv analyzer and strconv.Itoa
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)ors[: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.Clonemakes an independent copy;copycopiesmin(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.
Source: slices.Clone, runtime.MemStats and strings.Clone
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] = 100was 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
Source: Specification: Defer statements
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.
Source: Specification: Defer statements and defers analyzer
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.Contextit 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
selectagainstctx.Done(). -
The sender, not the receiver, closes a channel, and only once.
-
Start goroutines with
sync.WaitGroup.Go(Go 1.25) orerrgroupso 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.
Source: Specification: Send statements and Effective Go: Channels
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.
Source: sync.WaitGroup.Go and waitgroup analyzer
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.
Source: sync.Mutex and copylock analyzer
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.
Source: net/http: Response and httpresponse analyzer
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
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.
Source: Go 1.26 release notes and cmd/fix
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 |
Compare errors with |
|
|
It produces a |
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. |
|
It is a vet report ( |
Time-based loops with |
Timers and tickers are garbage-collected when unreferenced and timer channels are synchronous since Go 1.23. |
Start a worker with |
|
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:
-
Is every
errorchecked, and is everyCloseon a writer checked? Does a function return a typed pointer through an interface result? -
Does a goroutine have a way to finish? Who closes or drains each channel? Is every
Addbefore itsgo? -
Is a map or slice written by more than one goroutine without a lock? Is the race detector (
go test -race) in CI? -
Does a
defersit in a loop, or evaluate an argument that should be evaluated later? -
Is a
:=inside a block shadowing an outererror a result? -
Could an
appendshare memory with another slice, or does a small slice retain a large array? -
Do integer conversions and arithmetic on sizes, indexes and durations stay in range?
-
Does every HTTP client have a timeout, a closed body and a status check? Does every server have timeouts?
-
Is
go vet ./...(not onlygo test) in CI, and is thegoline 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 |
|
Go 1.22 |
Per-iteration loop variables for modules with |
Go 1.23 |
Timers and tickers collectable and unbuffered timer channels; |
Go 1.25 |
|
Go 1.26 |
|
Go 1.27 |
The |
References
-
Go FAQ, in particular nil error, closures and goroutines and map operations and atomicity
-
The Go Programming Language Specification: for range, defer, integer overflow, order of evaluation
-
The Go Blog: Go Slices: usage and internals, Go maps in action, Strings, bytes, runes and characters in Go, Defer, Panic, and Recover, Fixing For Loops in Go 1.22 and Working with Errors in Go 1.13
-
Release notes: 1.14, 1.18, 1.21, 1.22, 1.23, 1.25, 1.26 and 1.27