Craft ⏱ 15 min read

05 · Anti-Patterns

Common Go mistakes — recognize and avoid them

Ignoring Errors

Never use _ to discard an error unless you have an explicit reason.
// BAD
result, _ := db.Query("SELECT ...")  // error silently dropped

// GOOD
result, err := db.Query("SELECT ...")
if err != nil {
  return fmt.Errorf("query failed: %w", err)
}

Goroutine Leaks

Goroutines are cheap, but leaking them (starting without a way to stop) exhausts memory over time.

// BAD — goroutine runs forever with no way to cancel
go func() {
  for {
    process()
    time.Sleep(time.Second)
  }
}()

// GOOD — use a context for cancellation
go func(ctx context.Context) {
  for {
    select {
    case <-ctx.Done():
      return // exits cleanly
    default:
      process()
      time.Sleep(time.Second)
    }
  }
}(ctx)

Global Mutable State

// BAD — package-level var shared across all requests
var currentUser string

func HandleRequest(c *fiber.Ctx) error {
  currentUser = c.Get("X-User-ID") // race condition!
  return process()
}

// GOOD — pass state through context or function arguments
func HandleRequest(c *fiber.Ctx) error {
  userID := c.Get("X-User-ID")
  return process(c.UserContext(), userID)
}

Panic in Library / Handler Code

// BAD
func GetCoupon(id string) *Coupon {
  if id == "" {
    panic("id cannot be empty") // kills the server process
  }
  // ...
}

// GOOD
func GetCoupon(id string) (*Coupon, error) {
  if id == "" {
    return nil, errors.New("id cannot be empty")
  }
  // ...
}

Overusing interface{} / any

// BAD — loses all type safety
func Process(data interface{}) {
  // need type assertions everywhere, runtime panics if wrong
  name := data.(map[string]string)["name"]
}

// GOOD — define a concrete type
type ProcessInput struct {
  Name string
}
func Process(data ProcessInput) {
  name := data.Name
}

init() Abuse

init() runs automatically when a package is imported — before main(). Surprising side effects (DB connections, file reads) in init() make code hard to test and reason about.

// BAD
func init() {
  db = connectToDatabase() // side effect on import
}

// GOOD — explicit initialization in main
func main() {
  db, err := connectToDatabase()
  if err != nil { log.Fatal(err) }
  // pass db to routes/handlers
}

Fiber ctx Value Reuse

Fiber reuses *fiber.Ctx across requests for performance. Values from c.Query(), c.Params(), c.Body() point to pooled memory — storing them outside the handler lifetime corrupts future requests.

Never pass fiber.Ctx values to goroutines or store them after the handler returns.
// BAD — goroutine reads stale/overwritten memory
func HandleOrder(c *fiber.Ctx) error {
  userID := c.Query("user_id") // pointer into pooled buffer
  go processAsync(userID)      // buffer may be reused before goroutine runs
  return c.SendStatus(202)
}

// GOOD — copy the value before spawning
func HandleOrder(c *fiber.Ctx) error {
  userID := c.Query("user_id")
  userIDCopy := strings.Clone(userID) // own copy on the heap
  go processAsync(userIDCopy)
  return c.SendStatus(202)
}

Loop Variable Capture in Goroutines

Goroutines launched inside a loop share the loop variable by reference. By the time the goroutine runs, the loop has already advanced — all goroutines see the last value.

// BAD — all goroutines print the same (last) item
items := []string{"a", "b", "c"}
for _, item := range items {
  go func() {
    fmt.Println(item) // captures &item, not the value
  }()
}

// GOOD — shadow the variable inside the loop body
for _, item := range items {
  item := item // new variable per iteration
  go func() {
    fmt.Println(item)
  }()
}

// ALSO GOOD — pass as argument
for _, item := range items {
  go func(s string) {
    fmt.Println(s)
  }(item)
}
Go 1.22+ fixes this for for loop variables, but loop variables in range over slices still follow old semantics in pre-1.22 code. Always be explicit.

Handling an Error Twice

Logging and returning an error causes duplicate log lines — each caller up the stack may log again.

// BAD — logged here AND by the caller
func GetUser(id string) (*User, error) {
  user, err := db.Find(id)
  if err != nil {
    log.Printf("db.Find failed: %v", err) // logged once
    return nil, err                        // caller logs again
  }
  return user, nil
}

// GOOD — return a wrapped error; log once at the top boundary
func GetUser(id string) (*User, error) {
  user, err := db.Find(id)
  if err != nil {
    return nil, fmt.Errorf("GetUser %s: %w", id, err)
  }
  return user, nil
}

// Log only at the handler/entry point
func HandleGetUser(c *fiber.Ctx) error {
  user, err := svc.GetUser(c.Params("id"))
  if err != nil {
    log.Printf("HandleGetUser: %v", err) // single log
    return fiber.ErrInternalServerError
  }
  return c.JSON(user)
}

String Concatenation in Loops

Using + to build strings in a loop allocates a new string on every iteration — O(n²) memory cost.

// BAD — n allocations for n iterations
result := ""
for _, part := range parts {
  result += part // allocates new string every time
}

// GOOD — single allocation
var b strings.Builder
for _, part := range parts {
  b.WriteString(part)
}
result := b.String()

defer Inside a Loop

defer runs when the surrounding function returns, not at the end of the loop iteration. In a long-running loop over files or DB rows, deferred closes stack up and never run until the function exits.

// BAD — files stay open until ProcessAll returns
func ProcessAll(paths []string) error {
  for _, p := range paths {
    f, err := os.Open(p)
    if err != nil { return err }
    defer f.Close() // stacks, not per-iteration
    process(f)
  }
  return nil
}

// GOOD — close in a helper function
func ProcessAll(paths []string) error {
  for _, p := range paths {
    if err := processOne(p); err != nil {
      return err
    }
  }
  return nil
}

func processOne(path string) error {
  f, err := os.Open(path)
  if err != nil { return err }
  defer f.Close() // runs when processOne returns — correct
  return process(f)
}

Slice Pre-allocation

Appending to a nil slice when the final size is known causes repeated reallocation and copying as the backing array grows.

// BAD — reallocates ~log₂(n) times
var results []Order
for _, row := range rows {
  results = append(results, toOrder(row))
}

// GOOD — allocate once
results := make([]Order, 0, len(rows))
for _, row := range rows {
  results = append(results, toOrder(row))
}

Maps Never Shrink

Deleting keys from a map frees the values but not the internal bucket array. A map that held 1 million keys still occupies ~the same memory after all keys are deleted.

// Problematic pattern in a long-running cache
cache := make(map[string][]byte)
// ... fill with 1M entries ...
for k := range cache {
  delete(cache, k) // values freed, buckets remain
}
// cache still holds ~same memory footprint

// Fix: replace the map entirely when bulk-clearing
cache = make(map[string][]byte) // old map GC-eligible
Use an LRU library or periodic map replacement for caches that grow and shrink frequently in production services.

Variable Shadowing with :=

Short variable declaration inside a block creates a new variable, not an assignment to the outer one. The outer variable is unchanged — a silent logic bug.

// BAD — err in outer scope never updated
var err error
if someCondition {
  result, err := doSomething() // new 'err' scoped to this block
  _ = result
  // outer err is still nil here
}
if err != nil { // always false — wrong!
  return err
}

// GOOD — assign, don't redeclare
var err error
var result Result
if someCondition {
  result, err = doSomething() // = not :=
}
if err != nil {
  return err
}

Key Takeaways