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 copied | What can remain shared? |
|---|---|
int, bool | No mutable data reachable through the value |
| Struct or array | Data reached through reference-containing fields or elements |
*T | The variable the pointer points to |
[]T | The backing array |
map[K]V | The 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.