sch39
Part 2 of 4 — Go Fundamentals for Developers

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:

  1. Lost mutations: code modifies a copy, not the original. The compiler doesn't complain.

  2. 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) and range i (index).

  • Explain why copying a struct containing a map can still mutate the map.

Implementation

Folder Structure

text
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.go

Setup

Initialize a single module (run from the go-series/ folder):

bash
mkdir -p G01-02-value-pointer
cd G01-02-value-pointer

go mod init github.com/sch39/go-series/g0102/valueptr

Code Blocks

Block 1: Value receiver — lost mutation

go-series/G01-02-value-pointer/item/item.go:

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:

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:

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:

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:

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:

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:

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:

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/:

bash
go test -count=1 -v ./...

Output:

bash
?   	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.970s

Why 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 := a creates an independent copy. Turns out the map/slice is still shared — mutating an element in b is visible in a.

  • Forgetting items[i] vs for _, 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 := range instead of for _, v := range.

  • Copying a struct containing a map/slice? Know that the data behind it stays shared.

  • Interface with a pointer receiver? Only *T satisfies it, not T.

  • Map of structs, need mutation? Store pointers: map[string]*T.

References