Common Gotchas
The mistakes Java developers make most often in their first months of Go, each with the fix. A checklist to skim before code review.
Section 29 of 315 min read
Most of these compile cleanly and pass a quick test. They show up later as a nil panic, a race, a leak or a hung request. Each links back to the section that explains it in depth.
Types and values
A nil pointer inside an interface is not nil
func find() error {
var err *NotFoundError // nil pointer
return err // non-nil error interface!
}
// Fix: return nil literally on the success path.An interface is nil only when both its type and value are nil. See any / interface{}.
Accidental shadowing with :=
var cfg *Config
if path != "" {
cfg, err := load(path) // declares a NEW cfg in this block
if err != nil {
return err
}
_ = cfg
}
use(cfg) // still nil
// Fix: declare err first and assign with =
var err error
cfg, err = load(path)Integer division and missing conversions
7 / 2 is 3, as in Java, but Go will not promote for you: float64(hits) / float64(total) needs both conversions written out. Converting a float to an int truncates toward zero, and converting between integer sizes silently wraps. See Types & Variables.
Comparing time.Time with ==
== on time.Time also compares the location and the monotonic clock reading, so two values for the same instant can be unequal. Use t1.Equal(t2), Before and After. Similarly, compare errors with errors.Is, not ==, because wrapping hides the original.
Collections
range gives you a copy
for _, u := range users {
u.Active = true // modifies a copy: users is unchanged
}
// Fix: index into the slice
for i := range users {
users[i].Active = true
}Sub-slices share memory with the original
b := a[1:3] does not copy. Writing to b, or appending to it while it has spare capacity, changes a. Use slices.Clone when you need independence. See Slices & Maps.
nil maps panic on write; map order is random
A declared-but-unmade map (var m map[string]int) can be read but panics on write: initialise it with make or a literal. Iteration order is deliberately randomised on every run, so tests that depend on it are flaky. Sort the keys with slices.Sorted(maps.Keys(m)) when order matters.
JSON numbers decode to float64
Unmarshalling into map[string]any produces float64 for every number, so v.(int) always fails, and integers above 2^53 lose precision. Decode into a typed struct instead, or use Decoder.UseNumber().
Concurrency
Concurrent map writes crash the process
Unlike a HashMap quietly corrupting itself, a Go map written by two goroutines at once can trigger fatal error: concurrent map writes, which cannot be recovered. Guard shared maps with a mutex. See Sync Primitives.
Mutexes are not reentrant, and must not be copied
Locking a sync.Mutex that the same goroutine already holds deadlocks. Copying a struct that contains a mutex copies the lock state: pass such structs by pointer. go vet reports the copies. See Sync Primitives.
A panic in any goroutine kills the program
There is no per-thread uncaught-exception handler. net/http recovers panics in handlers, but not in goroutines those handlers start. Recover at the top of long-lived goroutines. See Defer — Advanced.
Goroutines that never end
A goroutine blocked on a channel nobody will ever use again is leaked forever, with everything it references. Every go statement needs a known way to stop: a closed channel, a cancelled context, or a finite loop. See Goroutine Management.
Unbounded fan-out
Goroutines are cheap; database connections, file descriptors and third-party rate limits are not. Starting one goroutine per item for 100,000 items can exhaust them. Bound concurrency with errgroup.SetLimit or a semaphore channel.
Data races that "work"
A boolean flag or counter shared without synchronisation may appear to work for months. Go has no volatile, and the compiler may legally cache or reorder unsynchronised accesses. Use sync/atomic or a mutex, and run go test -race in CI.
Errors and cleanup
defer inside a loop
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // runs at FUNCTION exit, not iteration end
process(f)
}
// Fix: one function call per iteration
for _, path := range paths {
if err := processFile(path); err != nil { // defer inside processFile
return err
}
}Logging an error and returning it
Each layer that logs and returns duplicates the same failure in your logs. Wrap with context and return; log once where the error is finally handled. See Error Handling.
log.Fatal and os.Exit skip deferred calls
They exit immediately: no deferred Close, no flushed buffers, no graceful shutdown. Use them only in main, and prefer a run() error function. See Init & Main Lifecycle.
HTTP and I/O
The default HTTP client never times out
http.Get and http.DefaultClient have no timeout, so one unresponsive dependency can hang goroutines indefinitely. Create an http.Client with a Timeout and pass a context to every request. Close every response body, and check resp.StatusCode yourself: a 500 is not an error. See JSON & HTTP APIs.
Headers set after WriteHeader are ignored
The first call to WriteHeader or Write sends the status line and headers. Setting Content-Type afterwards silently does nothing, and calling WriteHeader twice logs "superfluous response.WriteHeader call". Set headers, then the status, then the body.
string(n) is not strconv.Itoa(n)
string(65) is "A": the integer is treated as a Unicode code point. Use strconv.Itoa or fmt.Sprint for decimal text. go vet flags the conversion. See Strings & Formatting.