Go's Copy-on-Assignment Semantics and Other Easy-to-Miss Details

Go's reputation as a simple language can be deceptive. Even after years of casual use, subtle behaviors around value semantics and slice mechanics can still produce surprising bugs. A recent deep-dive into 100 Go Mistakes and How To Avoid Them by Teiva Harsanyi uncovered several such misconceptions worth knowing about.

The Core Issue: Assignment Copies Structs

In Go, assigning a struct to a new variable doesn't create a reference — it creates a full copy. This is a fundamental semantic that's easy to overlook, especially if you're coming from languages where objects are essentially passed by reference.

Take this straightforward scenario:

type Thing struct {
    Name string
}

When you execute:

thing := Thing{"record"}
other_thing := thing
other_thing.Name = "banana"
fmt.Println(thing)

The output is "record", not "banana". The assignment other_thing := thing copied the struct, so subsequently modifying other_thing.Name doesn't affect the original thing variable at all.

A Real-World Bug Triggered by Range Loops

This copy-by-default behavior becomes particularly treacherous with slices and range loops. In a loop like this:

type Thing struct {
  Name string
}
func findThing(things []Thing, name string) *Thing {
  for _, thing := range things {
    if thing.Name == name {
      return &thing
    }
  }
  return nil
}

func main() {
  things := []Thing{Thing{"record"}, Thing{"banana"}}
  thing := findThing(things, "record")
  thing.Name = "gramaphone"
  fmt.Println(things)
}

You get [{record} {banana}] instead of [{banana} {banana}]. The reason: findThing returns a copy of the struct from the slice, not a pointer to it. Modifying that copy is meaningless.

The fix is to return a pointer into the slice itself:

func findThing(things []Thing, name string) *Thing {
  for i := range things {
    if things[i].Name == name {
      return &things[i]
    }
  }
  return nil
}

This way, changes to the returned object directly affect the element in the original array.

Why This Misconception Persists

The confusion often stems from how other languages behave. In Python or JavaScript, almost everything is a reference, so assignments naturally create aliases. In C-like languages, variables are typically pointers to heap-allocated objects.

Go, however, has value semantics for structs. You must make a conscious decision: pass a value (and accept the copy) or pass a pointer (and share access). This decision isn't just for function parameters — it applies to every assignment. Experience with languages where copying requires an explicit .clone() method call can also mask this behavior in Go.

Slices Share Backing Arrays

Another subtle gotcha involves slicing and appending. When you create a sub-slice with x[2:3], the original and new slices point to the same underlying array. Appending to the sub-slice can therefore corrupt the original slice:

x := []int{1, 2, 3, 4, 5}
y := x[2:3]
y = append(y, 555)
fmt.Println(x)

This code produces [1 2 3 555 5], where the value 4 was silently overwritten by an append to the sub-slice. A three-index slice operation like x[2:3:3] limits the capacity of the new slice, forcing append operations to allocate a fresh array instead of mutating the original one.

Value Receivers vs. Pointer Receivers

Go methods come in two flavors: value receivers (func (t Thing) Function()) and pointer receivers (func (t *Thing) Function()). The rule is simple but critical:

  • A pointer receiver allows the method to mutate the struct.
  • A value receiver guarantees the method won't mutate the original struct.

Even with this distinction clear, value vs. pointer receivers can still trigger confusing compile errors, especially when method sets and interface satisfaction come into play. Those details are worth reading up on.

More Go Pitfalls and Valuable Finds

A few other items from the resource stood out as particularly useful, some of which are easy to miss even for experienced developers:

  • Go naming conventions for these two receiver types isn't just a stylistic choice; it affects whether a type satisfies an interface, which can lead to subtle type errors.
  • Named result parameters exist, making func (…) (result int, err error) valid. However, mixing them with deferred functions can create unintended side effects, so they should be used carefully.
  • Tests can be placed in an external package (e.g., package mypkg_test) to force the code under test to expose its public API. This approach catches design flaws early on.

For anyone writing HTTP-handling code, it's important to remember the return statement after writing a response — an easy one to forget, especially right after hitting the success path. Similarly, Go's built-in httptest package is a powerful server-testing utility that often gets overlooked.

The "100 Common Mistakes" format, now available for languages like Java as well, proves valuable here. It's easy to quickly scan and evaluate each concern, classifying it as a known fact, irrelevant to your domain, or — in the best cases — a genuinely surprising and useful correction.

Beyond that book, a few other resources consistently deliver practical value. Go by example is great for refreshers on syntax, and go.dev/play is essential for experimentation. For linters, staticcheck gives solid baseline checks, like flagging unhandled errors, while the combined golangci-lint suite bundles many of these tools together under one config.