Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 080 / 100

Common Go mistakes: a practical guide for developers

Bugs every Go developer meets.

Expona Admin7 min read

Introduction

After 80 days of building production APIs in Go, we've learned that the language's simplicity is both a strength and a trap. Common Go mistakes: a practical guide for developers exists because even experienced engineers stumble on patterns that look deceptively straightforward. In this piece, we'll walk through the bugs every Go developer encounters, show you exactly what goes wrong, and share the patterns that prevent them in real systems.

Ignoring errors with the blank identifier

The underscore in Go lets you discard a return value. It's tempting to use it when you're certain an operation won't fail—and that confidence is usually misplaced. We've seen production outages where a team silently dropped an error from json.Unmarshal, spent hours debugging why data looked garbled, and finally discovered the JSON parser was failing on every request.

Here's the classic mistake:

go
func ParseUserRequest(data []byte) User {
  var user User
  _ = json.Unmarshal(data, &user)  // Error ignored
  return user
}

When the JSON is malformed, user stays zero-valued. Your downstream code handles an empty user object instead of failing fast. The bug is silent—no panic, no log, just wrong behavior.

The fix is straightforward: handle the error explicitly.

go
func ParseUserRequest(data []byte) (User, error) {
  var user User
  if err := json.Unmarshal(data, &user); err != nil {
    return User{}, fmt.Errorf("parse user: %w", err)
  }
  return user, nil
}

We've found that even logging the error is better than ignoring it. At minimum, keep _ = value only for truly impossible cases—and comment why it's safe. Most of the time, you'll want to handle the error, return it to the caller, or at least increment a metrics counter.

Nil map writes panic

Go lets you read from a nil map, but writing to one crashes your process. This catches teams during load testing when a goroutine finally tries to insert into an uninitialized map. The panic threads through your API, and you lose the entire request.

Here's where it happens:

go
type UserCache struct {
  data map[string]User  // nil by default
}

func (c *UserCache) Set(id string, user User) {
  c.data[id] = user  // panic: assignment to entry in nil map
}

The issue is that data is declared but never initialized. In a constructor or at declaration, you need to call make:

go
type UserCache struct {
  data map[string]User
}

func NewUserCache() *UserCache {
  return &UserCache{
    data: make(map[string]User),
  }
}

func (c *UserCache) Set(id string, user User) {
  c.data[id] = user  // OK
}

Another pattern is to check before writing, though it's less common:

go
func (c *UserCache) Set(id string, user User) {
  if c.data == nil {
    c.data = make(map[string]User)
  }
  c.data[id] = user
}

We prefer the constructor approach because it forces initialization at creation time. If you're lazy-initializing, be explicit and defensive. The worst-case scenario is a nil map sitting undiscovered until your peak traffic hits.

Capturing loop variables before Go 1.22

In Go versions before 1.22, loop variables were reused across iterations. If you spawned a goroutine inside the loop and captured the loop variable, the goroutine saw the final value—not the value at the iteration it was created. This is one of the most common gotchas in concurrent Go code.

Here's the broken pattern (Go <1.22):

go
func ProcessUsers(ids []string) {
  for _, id := range ids {
    go func() {
      fmt.Println("Processing", id)  // Wrong: all goroutines see the last id
    }()
  }
}

If ids is ["alice", "bob", "charlie"], all three goroutines might print "charlie" because by the time they execute, the loop has finished and id is "charlie".

Before Go 1.22, you had to capture the loop variable explicitly:

go
func ProcessUsers(ids []string) {
  for _, id := range ids {
    id := id  // Create a new binding for this iteration
    go func() {
      fmt.Println("Processing", id)  // OK
    }()
  }
}

Go 1.22 fixed this by creating a fresh loop variable for each iteration, so the shadowing trick is no longer necessary. If your team is on 1.22+, the simple version works. If you're still on 1.21, keep the extra assignment in your code or pass the variable as a parameter:

go
func ProcessUsers(ids []string) {
  for _, id := range ids {
    go func(userID string) {
      fmt.Println("Processing", userID)
    }(id)
  }
}

This mistake is particularly painful because the code compiles and runs—it just produces wrong results unpredictably. We've spent hours debugging race conditions that were actually loop variable capture. Check your Go version and audit goroutines inside loops if you're pre-1.22.

Forgetting to close response bodies

The http.Response body is an io.ReadCloser that holds a socket connection. If you don't call Close(), the connection stays open. In a service making hundreds of requests per second, these leaks accumulate into connection exhaustion and your API starts timing out or rejecting requests.

The typical mistake in client code:

go
func FetchUser(id string) (User, error) {
  resp, err := http.Get("https://api.example.com/users/" + id)
  if err != nil {
    return User{}, err
  }
  defer resp.Body.Close()  // Easy to forget
  
  var user User
  if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
    return User{}, err
  }
  return user, nil
}

The pattern is always to defer resp.Body.Close() right after checking the error. But there's a subtle gotcha: if you don't check the response status code before decoding, bad status codes still have bodies that need closing.

go
func FetchUser(id string) (User, error) {
  resp, err := http.Get("https://api.example.com/users/" + id)
  if err != nil {
    return User{}, err
  }
  defer resp.Body.Close()
  
  if resp.StatusCode != http.StatusOK {
    body, _ := io.ReadAll(resp.Body)
    return User{}, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
  }
  
  var user User
  if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
    return User{}, err
  }
  return user, nil
}

The defer runs regardless of how the function exits, so the body is always closed. Some teams use helper functions to reduce boilerplate:

go
func readJSON(resp *http.Response, v interface{}) error {
  defer resp.Body.Close()
  if resp.StatusCode != http.StatusOK {
    body, _ := io.ReadAll(resp.Body)
    return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
  }
  return json.NewDecoder(resp.Body).Decode(v)
}

func FetchUser(id string) (User, error) {
  resp, err := http.Get("https://api.example.com/users/" + id)
  if err != nil {
    return User{}, err
  }
  var user User
  return user, readJSON(resp, &user)
}

In production systems making thousands of external calls, this mistake is expensive. We've debugged socket exhaustion, container restarts, and cascading timeouts—all because response bodies weren't closed. Make it a habit: defer resp.Body.Close() always.

Common mistakes summary table

Mistake Symptom Fix
Ignoring errors with _ Silent failures, wrong data downstream Handle errors explicitly, return them or log
Nil map writes Panic on write, process crash Initialize maps with make in constructor
Loop variable capture (<1.22) Goroutines see final value, wrong results Shadow loop var or pass as parameter
Unclosed response bodies Socket exhaustion, connection timeouts Always defer resp.Body.Close()

Key takeaways

  • Never use the blank identifier to silence errors. At minimum, log them or increment a metric. Errors are data about what went wrong.
  • Always initialize maps with make in constructors or early in function execution. Nil maps are readable but not writable.
  • If you're on Go <1.22, capture loop variables explicitly when launching goroutines. Go 1.22 fixes this, so update if possible.
  • Close all http.Response bodies with defer, even on error status codes. This prevents socket leaks in production.
  • These mistakes compile and run—they fail in production under load or unpredictably. Code review and testing catch them early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles