Skip to content
Go forJava Developers

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

On this page
  1. Types and values
  2. Collections
  3. Concurrency
  4. Errors and cleanup
  5. HTTP and I/O

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

err != nil even though nothing failed
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 :=

the outer cfg is never assigned
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

modifying the loop variable does nothing to the slice
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

every file stays open until the function returns
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.

↑ ↓ to navigateEnter to openEsc to close