Value vs Pointer Semantics in Go
Oct 2026 · 5 min read
Everything in Go is passed by value — arguments, range elements, method receivers. Copies are shallow — structs containing maps/slices still share backing data. Mutations to a copy aren't visible unless you pass a pointer.
TL;DR: Everything in Go is passed by value — arguments, range elements, method receivers. Copies are shallow — structs containing maps/slices still share backing data. Mutations to a copy aren't visible unless you pass a pointer. Full code link: https://github.com/Sch39/go-series
Problem
Developers coming from Java/JS/Python are used to objects = references. In Go, a struct is a value. This results in two classes of bugs:
Lost mutations: code modifies a copy, not the original. The compiler doesn't complain.
Leaked mutations: the struct has been copied, but the maps/slices inside it turn out to still be shared.
Outcome
Readers will be able to:
Predict when a mutation is visible and when it isn't when passing a value vs a pointer.
Understand the difference between
range(copy) andrange i(index).Explain why copying a struct containing a map can still mutate the map.
Implementation
Folder Structure
go-series/G01-02-value-pointer/
├── go.mod
├── item/
│ ├── item.go
│ └── item_test.go
├── counter/
│ ├── counter.go
│ └── counter_test.go
└── data/
├── data.go
└── data_test.goSetup
Initialize a single module (run from the go-series/ folder):
mkdir -p G01-02-value-pointer
cd G01-02-value-pointer
go mod init github.com/sch39/go-series/g0102/valueptrCode Blocks
Block 1: Value receiver — lost mutation
go-series/G01-02-value-pointer/item/item.go:
package item
type Item struct {
Name string
Qty int
}
func AddQtyValue(it Item) {
it.Qty++
}
func AddQtyPointer(it *Item) {
it.Qty++
}Goal: Show the difference between a value receiver (copy) and a pointer receiver (address).
Block 2: Range copy vs index reference
go-series/G01-02-value-pointer/item/item.go:
func AddQtyRange(items []Item) {
for _, item := range items {
item.Qty++ // copy of the slice element
}
}
func AddQtyIndex(items []Item) {
for i := range items {
items[i].Qty++ // the original element in the slice
}
}Goal: The range variable is a copy, indexing is a reference to the original element in the slice.
Block 3: Map of struct — read-modify-write pattern
go-series/G01-02-value-pointer/item/item.go:
func AddQtyMap(items map[string]Item, key string) {
item := items[key] // copy the value from the map
item.Qty++ // modify the copy
items[key] = item // store it back into the map
}Goal: Map elements are not addressable, so you must read-modify-write. Or use map[string]*Item if you want pointers.
Block 4: Plain vs pointer method receiver
go-series/G01-02-value-pointer/counter/counter.go:
package counter
type Incrementer interface {
Inc()
}
type Counter struct {
n int
}
func (c *Counter) Inc() {
c.n++
}
func (c *Counter) Value() int {
return c.n
}Goal: A pointer receiver allows the mutation to be visible. A value receiver does not satisfy an interface with a pointer receiver.
Block 5: Shallow copy — maps and slices stay shared
go-series/G01-02-value-pointer/data/data.go:
package data
type Data struct {
Tags map[string]bool
Scores []int
}
func (s Data) AppendScore() {
s.Scores = append(s.Scores, 1)
}
func (s Data) ChangeFirstScore() {
s.Scores[0] = 99
}Goal: Copying a struct copies the map/slice header, not the data behind it. Element mutations are visible; append is not.
Test Blocks
Test 1: Value receiver vs pointer receiver
go-series/G01-02-value-pointer/item/item_test.go:
package item_test
import (
"testing"
"github.com/sch39/go-series/g0102/valueptr/item"
)
func TestAddQtyValue(t *testing.T) {
it := item.Item{Name: "Book", Qty: 5}
item.AddQtyValue(it)
if it.Qty != 5 {
t.Errorf("AddQtyValue changed value; got %d, want 5", it.Qty)
}
}
func TestAddQtyPointer(t *testing.T) {
it := item.Item{Name: "Book", Qty: 5}
item.AddQtyPointer(&it)
if it.Qty != 6 {
t.Errorf("AddQtyPointer did not change; got %d, want 6", it.Qty)
}
}Goal: Prove that a mutation in a value receiver is not visible, but in a pointer receiver it is.
Test 2: Range vs index
go-series/G01-02-value-pointer/item/item_test.go:
func TestRangeVsIndex(t *testing.T) {
items := []item.Item{{Qty: 5}, {Qty: 10}}
item.AddQtyRange(items)
if items[0].Qty != 5 || items[1].Qty != 10 {
t.Errorf("AddQtyRange should not change slice; got %+v", items)
}
item.AddQtyIndex(items)
if items[0].Qty != 6 || items[1].Qty != 11 {
t.Errorf("AddQtyIndex should change slice; got %+v", items)
}
}Goal: The range variable is a copy, indexing is a reference.
Test 3: Shallow copy — maps/slices stay shared
go-series/G01-02-value-pointer/data/data_test.go:
package data_test
import (
"testing"
"github.com/sch39/go-series/g0102/valueptr/data"
)
func TestShallowCopy(t *testing.T) {
a := data.Data{
Tags: map[string]bool{"go": true},
Scores: []int{10, 20},
}
b := a
// Map stays shared
b.Tags["x"] = true
if !a.Tags["x"] {
t.Errorf("map mutation should be visible in a; a.Tags=%v", a.Tags)
}
// Slice elements stay shared
b.ChangeFirstScore()
if a.Scores[0] != 99 {
t.Errorf("slice element mutation should be visible in a; got %d, want 99", a.Scores[0])
}
// Slice append is not visible
lenBefore := len(a.Scores)
b.AppendScore()
if len(a.Scores) != lenBefore {
t.Errorf("slice append in copy should not be visible in a; got len=%d, want %d", len(a.Scores), lenBefore)
}
}Goal: Prove that a struct copy is shallow — the map/slice header is copied, but the data behind it is shared.
Run Test
Run from folder go-series/G01-02-value-pointer/:
go test -count=1 -v ./...Output:
? github.com/sch39/go-series/g0102/valueptr/counter [no test files]
=== RUN TestShallowCopy
--- PASS: TestShallowCopy (0.00s)
PASS
ok github.com/sch39/go-series/g0102/valueptr/data 0.156s
=== RUN TestAddQtyValue
--- PASS: TestAddQtyValue (0.00s)
=== RUN TestAddQtyPointer
--- PASS: TestAddQtyPointer (0.00s)
=== RUN TestRangeVsIndex
--- PASS: TestRangeVsIndex (0.00s)
PASS
ok github.com/sch39/go-series/g0102/valueptr/item 2.970sWhy It Works
Value semantics: All parameters, receivers, and range variables are copies. Go copies structs field-by-field. A field of pointer type points to the same address, so mutating the data behind the pointer is visible. A field of slice or map type is a small header (pointer + metadata), so element mutations remain visible, but reassignment does not.
Method receiver: The receiver is the function's first parameter. func (c Counter) Foo() is equivalent to func Foo(c Counter) — a copy. func (c *Counter) Foo() is equivalent to func Foo(c *Counter) — the address.
Interface implementation: Type T satisfies an interface only if T's method set meets the interface. *T and T have different method sets. If an interface needs a method with a pointer receiver, only *T satisfies it.
Edge Cases
Map elements are not addressable: m["a"].field = x is a compile error. You have to read-modify-write or use map[string]*T.
Nil pointer receiver: It's possible — a pointer can be nil, and the method can check for nil inside.
Interface{} with a value: var x interface{} = someStruct = copy the struct into the interface value.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
Value receiver | Immutable-by-default, simple | Mutations get lost silently |
Pointer receiver | Mutations are visible, explicit | Needs nil checks, more verbose |
Pointer in struct | Shared semantics | Shallow copy surprises |
Best practice: Value receiver for immutable or lightweight. Pointer receiver if you need visible mutations. Be consistent within a type — don't mix value and pointer receivers.
Gotchas I've Hit
Thinking
b := acreates an independent copy. Turns out the map/slice is still shared — mutating an element inbis visible ina.Forgetting
items[i]vsfor _, item := range— naively optimizing a loop with range, but not realizing the elements aren't changing.Changing a method receiver from value to pointer and then the interface consumption fails — a type that already satisfied the interface as a value doesn't satisfy it as a pointer.
Production Checklist
Mutating a struct in a method? Use a pointer receiver consistently.
Looping to change slice elements? Use
for i := rangeinstead offor _, v := range.Copying a struct containing a map/slice? Know that the data behind it stays shared.
Interface with a pointer receiver? Only
*Tsatisfies it, notT.Map of structs, need mutation? Store pointers:
map[string]*T.
References
Effective Go, Receivers: https://go.dev/doc/effective_go#receiver
Go spec, Method sets: https://go.dev/ref/spec#Method_sets
Go spec, Slice types: https://go.dev/ref/spec#Slice_types
Go spec, Map types: https://go.dev/ref/spec#Map_types