Go Under the Hood · Part 2

Values, Pointers, and Copies in Go

Understand Go's pass-by-value semantics, pointer aliasing, shallow copies, and ownership through runnable examples that reveal which mutations reach callers.

A function receives a struct, changes its name, and returns. The caller sees the old name. The same function changes an element in the struct’s slice, and the caller sees the new element. Did Go switch from copying to passing by reference halfway through?

Go passes every argument by value. A copied value can still refer to shared data. Distinguishing the value you copy from the data it reaches explains both results. Go FAQ: passing by value

This is Part 2 of Go Under the Hood. Part 1 followed a program into main; this article examines assignments and calls inside it. You should recognise functions, structs, and slices. Examples target Go 1.27 and were verified with Go 1.27.1 on macOS ARM64, using only the standard library.

Ask what the copied value contains

A function parameter is a separate variable. Reassigning it changes that variable. Whether other changes reach the caller depends on the value’s contents.

Value copiedWhat can remain shared?
int, boolNo mutable data reachable through the value
Struct or arrayData reached through reference-containing fields or elements
*TThe variable the pointer points to
[]TThe backing array
map[K]VThe underlying map

Copying a struct copies its fields, including any pointers or slice values. It does not recursively duplicate everything reachable from those fields. A slice value contains a length, capacity, and reference to an underlying array. Go specification: representation of values

For this article, aliasing means that two paths reach the same mutable storage. You can discover an alias by changing something through one path and observing it through the other. You do not need to print memory addresses.

Experiment 1: copying a value and copying a pointer

Create a small module using a Go 1.27 toolchain:

go version
mkdir value-demo
cd value-demo
go mod init example.com/value-demo
go mod edit -go=1.27.0

Save this as main.go:

package main

import "fmt"

type Counter struct {
	N int
}

func incrementValue(c Counter) {
	c.N++
}

func incrementPointer(c *Counter) {
	c.N++
}

func replacePointer(c *Counter) {
	c = &Counter{N: 100}
	fmt.Println("inside replacement:", c.N)
}

func main() {
	original := Counter{N: 1}
	copied := original
	copied.N = 10
	fmt.Println("assignment:", original.N, copied.N)

	incrementValue(original)
	fmt.Println("value call:", original.N)

	p := &original
	q := p
	incrementPointer(q)
	fmt.Println("pointer call:", original.N, p == q)

	replacePointer(p)
	fmt.Println("after replacement:", p.N, p == q)
}

Run it:

go run .
go vet ./...

Expected output:

assignment: 1 10
value call: 1
pointer call: 2 true
inside replacement: 100
after replacement: 2 true

The first two results expose independent Counter values. Changing copied.N leaves original.N alone. incrementValue receives another independent counter, so its increment disappears when the call returns.

The pointer call behaves differently. &original takes the address of original. Both p and q hold that address, and the parameter receives another copy of it. c.N++ follows the pointer and updates the original counter.

p ──────────────┐
q ──────────────┼──→ original: Counter{N: 2}
parameter c ───┘

For a pointer to a struct, c.N is shorthand for (*c).N: *c accesses the pointed-to value. Taking an address does not clone the target. Dereferencing a nil pointer panics. Go specification: address operators

Replacing a pointer is different from changing its target

The misleading function in the example is replacePointer. Its assignment makes the local parameter point to a new counter. It leaves p, q, and their target unchanged. The 100 exists inside that call; the caller still observes 2.

If the intended operation is to overwrite the existing counter, replace the function’s assignment with:

*c = Counter{N: 100}

Now the final line becomes after replacement: 100 true. Both pointers still agree because the assignment changed the object they already shared.

If the intended operation is to select a different object for the caller, an explicit return is often clearer:

func replacement() *Counter {
	return &Counter{N: 100}
}

Use p = replacement() in main. This time p.N is 100, while q.N remains 2: only p was redirected. A **Counter parameter could also let a function update the caller’s pointer variable, but that extra indirection is unnecessary for this simple API.

The failure was an incorrect expectation about the assignment’s target. Before changing a signature, identify whether you want to replace a local variable, mutate a shared object, or return a new object.

Experiment 2: a struct copy can still share storage

Replace main.go with this independent program:

package main

import (
	"fmt"
	"maps"
	"slices"
)

type Profile struct {
	Name   string
	Scores []int
	Labels map[string]string
}

func edit(p Profile) {
	p.Name = "edited"
	p.Scores[0] = 99
	p.Labels["tier"] = "gold"
	p.Scores = []int{7, 8}
	p.Labels = map[string]string{"tier": "local"}
}

func main() {
	original := Profile{
		Name:   "original",
		Scores: []int{10, 20},
		Labels: map[string]string{"tier": "basic"},
	}

	edit(original)
	fmt.Println("after edit:", original.Name,
		original.Scores, original.Labels["tier"])

	isolated := original
	isolated.Scores = slices.Clone(original.Scores)
	isolated.Labels = maps.Clone(original.Labels)
	isolated.Name = "isolated"
	isolated.Scores[0] = 5
	isolated.Labels["tier"] = "silver"

	fmt.Println("original:", original.Name,
		original.Scores, original.Labels["tier"])
	fmt.Println("isolated:", isolated.Name,
		isolated.Scores, isolated.Labels["tier"])
}

Run the same go run . and go vet ./... commands. Expected output:

after edit: original [99 20] gold
original: original [99 20] gold
isolated: isolated [5 20] silver

Inside edit, the local Name assignment stays local. The element write reaches the shared backing array, and the map entry write reaches the shared map. Replacing the local slice and map afterwards does not undo those earlier mutations or redirect the caller’s fields.

This is a useful failure case for an API that promises to leave its input unchanged. A value parameter alone does not deliver that promise. In this experiment, such an API would already have broken its contract before returning.

Clone the data at the boundary that needs independence

The second half of the profile example creates independent containers before making further edits. slices.Clone copies the elements into a clone; it is a shallow copy. For []int, those copied elements are enough to isolate element writes. For []*Counter, the copied elements would still point to the original counters. slices.Clone documentation

Likewise, maps.Clone creates a shallow map clone. The example’s string keys and string values need no further mutable-object copying. A map whose values are pointers would still share their targets. maps.Clone documentation

Treat a clone operation as a specific promise. Does it duplicate just the container, each mutable element, or an entire object graph? If the data contains cycles or repeated references, a recursive clone also needs a policy for preserving those relationships.

Cloning is unnecessary when sharing is intentional and the participants agree on mutation. It is useful when a caller needs an independent snapshot or an API retains data that the caller may later change. Choose that boundary deliberately instead of copying every object defensively.

Make ownership conventions explicit

Here, ownership describes an API agreement about who may mutate or retain data. A *Profile parameter does not express exclusive ownership, and a Profile parameter does not promise immutability.

For a proposed configuration API, document behaviour such as:

  • “The function reads the input during the call and does not retain it.”
  • “The function updates this object; callers must coordinate access.”
  • “The constructor copies the supplied slice and its mutable elements.”
  • “After handing over this buffer, the caller must stop using it.”

These are design choices, not contracts automatically enforced by the signatures. Review every reference-containing field against the chosen agreement. Our profile example would satisfy none of the first API’s expectations if it silently edited scores while claiming to read only.

Some library types have stricter copying rules. A sync.Mutex must not be copied after first use; embedding one in a struct makes casual struct copies dangerous. Follow the type’s documentation, and use go vet as an additional check. sync.Mutex documentation

Pointers do not prove a performance improvement

Choosing a pointer changes sharing and mutation semantics. It does not by itself establish where storage lives or whether the program gets faster. Go can safely return the address of a local variable; storage placement is a compiler decision. Optimisations may also remove physical copies while preserving observable behaviour. Go FAQ: stack and heap allocation, Go FAQ: passing by value

This article measures correctness, not speed or allocations. Decide the API’s semantics first. If copying later appears expensive, investigate a representative workload using the approach in A Measurement-First Performance Investigation.

Exercise: find the sharing that a clone preserves

In Experiment 2, remove the slices.Clone line and its now-unused slices import, then rerun. Predict which output changes: the original scores now become [5 20]. Restore both, then remove the maps.Clone line and its now-unused maps import. The original tier now becomes silver.

For a second exercise, replace main.go with:

package main

import (
	"fmt"
	"slices"
)

type Counter struct{ N int }

func main() {
	original := []*Counter{{N: 1}}
	copied := slices.Clone(original)
	copied[0].N = 9
	fmt.Println(original[0].N, copied[0].N)
}

It prints 9 9. Explain why the slice clone did not create a second counter, then change the code so it prints 1 9. One solution is to copy *original[0] into a new local counter and assign its address to copied[0] before the mutation.

For each assignment or call, ask: which value is copied, which storage remains shared, and who is allowed to change it? The next part, Arrays and Slices Under the Hood, applies those questions to backing arrays, capacity, and append.

Sources

Back to the journal