Value Semantics and Pointer Semantics
Go has exactly one parameter-passing rule: everything is passed by value. A function always receives a copy of its arguments, an assignment always copies, and a
returncopies (Go FAQ). What looks like reference behavior — modifying a slice element inside a callee and seeing the change outside — is not an exception to the rule but a consequence of what gets copied. The Go spec divides every value into two camps: self-contained values, where the copy is a complete, independent duplicate of all the data (numbers,bool,string, arrays, structs), and values that contain references to shared underlying data (pointers, slices, maps, channels, functions, and interfaces holding any of those) (Go spec). Copying a self-contained value gives you isolation; copying a reference-bearing value gives you a second handle onto the same backing storage. “Value semantics vs pointer semantics” is the discipline of choosing, deliberately, which of those two outcomes you want for a given type — and it drives correctness, the cost model, and the escape behavior of nearly every Go program.
Mental Model
The mental model is: a Go value is a fixed-size bag of bytes, and the only question is what those bytes mean. For an int, the bytes are the number — copy them and you have an independent number. For a [1024]byte array, the bytes are all 1024 bytes — copy them and you have an independent kilobyte. For a *T pointer, the bytes are an address — copy them and you have a second pointer aimed at the same object. For a slice, the bytes are a three-word descriptor (pointer, length, capacity) — copy them and you have a second descriptor whose pointer field aims at the same backing array (Go blog, slice internals).
Value semantics means: operate on a copy; mutations are local; the original is untouched; the type is its data. Pointer semantics means: operate through an address; mutations are visible everywhere the address is held; the type refers to its data. Go does not have a &-by-default like C++ references — you opt into pointer semantics explicitly by taking an address (&x) or by using a type that is itself a reference descriptor (slice, map, channel).
flowchart TD subgraph VS["Value semantics — copy is independent"] A1["caller: x = Point{1,2}"] -->|copy bytes| A2["callee: p (its own Point{1,2})"] A2 -.->|"p.X = 99 affects only p"| A3["caller's x still {1,2}"] end subgraph PS["Pointer semantics — copy shares backing"] B1["caller: x = Point{1,2}"] -->|"pass &x"| B2["callee: p *Point<br/>(copy of the address)"] B2 -->|"p.X = 99 writes through"| B3["caller's x now {99,2}"] end
Diagram: the same Point passed two ways. The insight: in both cases something is copied — in value semantics the copied thing is the Point’s bytes, in pointer semantics the copied thing is an address. Go never stops copying; you only choose what.
Mechanical Walk-through
When a function is called, the compiler arranges for each argument’s bytes to be placed where the callee expects them — historically on the stack, and since Go 1.17 increasingly in registers under the register-based ABI. Either way, the bytes are copied from the caller’s storage into the callee’s. For an int, that is 8 bytes (on a 64-bit target). For a struct{ a, b, c int }, it is 24 bytes. For a [1<<16]byte array, it is 64 KiB copied on every call — which is why large arrays are almost never passed by value.
A slice argument copies exactly three machine words: the data pointer, the length, and the capacity (Go blog). The callee gets its own descriptor — it can re-slice or append and the caller will not see the new length — but the pointer field in that descriptor still points at the caller’s backing array. So s[0] = 99 inside the callee writes into shared memory and is visible to the caller, while s = append(s, 99) inside the callee may not be visible, because append can replace the descriptor’s pointer with a freshly allocated array. This split — the descriptor is copied (value semantics) but it points at shared data (pointer semantics on the elements) — is the single most misunderstood thing about Go and the root of append surprises.
Maps and channels are even simpler: the value of a map[K]V or a chan T is, internally, a single pointer to a runtime structure (hmap, hchan). Copying a map value copies that one pointer, so two map variables that were assigned from each other are two handles on one hash table — inserting through either is visible through both. This is why a map parameter does not need to be *map to be mutable: the map is already a reference.
Arrays and structs are the pure value types. var b = a where both are [3]int produces a genuinely independent array; b[0] = 9 does not touch a. The same holds for structs — a struct value contains a complete copy of every field (Go spec). Critically, if a struct contains a slice or pointer field, the copy is shallow: the slice header is duplicated but its backing array is shared, the pointer is duplicated but its pointee is shared. There is no deep-copy operator in Go; deep copying is always manual.
Interface values are a special two-word case. An interface holds a type word and a data word. When you store a concrete value into an interface, what goes into the data word depends on the value’s size and shape: a pointer-shaped value is stored directly, while a larger value is boxed — the runtime allocates a copy on the heap and the data word points at the box. Copying the interface copies the two words; if the data word is a pointer to a box, both interface copies share the box. This boxing is also why putting a value into an interface{} is a classic cause of heap escape — see Interface Internals.
The formal guarantee behind all of this lives in the Go Memory Model: a copy is a read followed by a write of bytes, and concurrent unsynchronized access to the shared storage that reference-bearing copies point at is a data race. Value-semantic copies are race-free precisely because each goroutine gets its own bytes.
Code Examples
A struct passed by value versus by pointer:
type Counter struct{ n int }
func incValue(c Counter) { c.n++ } // mutates the local copy
func incPtr(c *Counter) { c.n++ } // mutates through the address
func main() {
c := Counter{}
incValue(c) // copies 8 bytes into the callee
println(c.n) // 0 — caller's Counter untouched
incPtr(&c) // copies the address of c
println(c.n) // 1 — write went through to caller's storage
}incValue receives an independent Counter; its c.n++ increments bytes that are discarded when the function returns. incPtr receives a copy of the pointer &c — the pointer is copied (value semantics on the pointer itself) but it still names main’s c, so the write lands in the caller’s variable.
The slice subtlety, made explicit:
func mutateElem(s []int) { s[0] = 99 } // writes shared backing array
func growSlice(s []int) { s = append(s, 7) } // may detach; caller won't see
func main() {
s := []int{1, 2, 3}
mutateElem(s)
println(s[0]) // 99 — element write is visible
growSlice(s)
println(len(s)) // 3 — append's new length stayed in the callee
}mutateElem copies the three-word header; the header’s pointer still aims at main’s array, so s[0] = 99 is visible. growSlice also copies the header, but append either grows in place (visible) or allocates a new array and rebinds the callee’s header pointer (not visible) — and you cannot tell which from the call site, which is exactly the trap. To make growth visible, pass *[]int or return the slice.
Choosing the receiver type for methods:
type Buffer struct{ data []byte }
func (b Buffer) Len() int { return len(b.data) } // value receiver: read-only, cheap-ish
func (b *Buffer) Append(p []byte) { b.data = append(b.data, p...) } // pointer receiver: must mutateLen uses a value receiver because it only reads — though note even this copies the Buffer struct on every call. Append must use a pointer receiver: it reassigns b.data, and a value receiver would discard that reassignment when the method returned. The method-set rule follows: a *Buffer can call both methods, a plain Buffer value can call both only if it is addressable; an unaddressable Buffer (e.g., a map element) cannot call Append.
Failure Modes and Common Misunderstandings
The headline misconception is “slices and maps are passed by reference, structs by value.” They are all passed by value — what differs is that slice/map/channel values are themselves reference descriptors. Repeating the mantra “everything is copied; the question is what” defuses every related bug.
A second trap is mutating a copy and expecting it to stick. Iterating with for _, v := range items gives v as a copy of each element; v.Field = x changes the copy, not the slice element. The fix is items[i].Field = x (index, do not range-copy) — see Range Loop Semantics.
A third is the shallow-copy surprise. Copying a struct that embeds a slice or pointer does not deep-copy. cfg2 := cfg1 followed by cfg2.Tags[0] = "x" mutates cfg1.Tags too, because both structs’ Tags headers point at one array. Many “spooky action at a distance” bugs are exactly this.
A fourth: copying a struct that contains a sync.Mutex or sync.WaitGroup. These types must not be copied after first use — a copied mutex is a different lock, so two goroutines “synchronizing” on the two copies are not synchronizing at all. go vet’s copylocks check catches the common cases; the rule is to hold such types behind a pointer.
Finally, the large-value-by-value performance cliff: passing a multi-kilobyte array or a fat struct by value copies it on every call and every return. The symptom is unexplained CPU in runtime.memmove and stack pressure. The cure is a pointer receiver/parameter — but see the next section, because pointers are not free either.
Alternatives and When to Choose Them
The choice between value and pointer semantics is a real engineering decision, not a style preference.
Prefer value semantics for small, copy-cheap, immutable-by-intent data: coordinates, IDs, time.Time, small option structs, anything you want to be safely shareable across goroutines without synchronization. Value semantics give you automatic isolation — a value copy can never be a data race — and they keep data on the stack, because a value that never has its address escape does not need the heap. The Go standard library leans heavily value-semantic for small types for exactly these reasons.
Prefer pointer semantics when the type must be mutated in place (the method genuinely changes state), when the type is large enough that copying it is a measurable cost, when the type must have a single identity (a *sync.Mutex, a connection, a cache), or when the type contains an uncopyable field. The cost is that a pointer often forces the pointee onto the heap (escape analysis can no longer prove it stays local), adding GC pressure, and that a *T can be nil, introducing a panic surface that a T value does not have.
A blanket “always use pointers, they’re faster” is wrong: for small structs, value passing is frequently faster — it avoids an allocation, avoids a GC scan, has better cache locality, and is immune to nil-dereference. The honest rule, echoed by the Go team’s own style guidance, is: pick one semantic per type and be consistent — do not mix value and pointer receivers on the same type without a deliberate reason, because mixed receivers make the method set and the copy-safety story ambiguous.
Production Notes
The most consequential production decision driven by value/pointer semantics is hot-path allocation. A request handler that builds a small result struct and returns it by value keeps that struct on the stack and never troubles the garbage collector; the same handler returning *Result typically forces a heap allocation per request, and at scale that is a measurable fraction of GC CPU. Profiling with go build -gcflags=-m (escape diagnostics) and pprof heap profiles routinely surfaces “this pointer return is the allocation” — see Escape Analysis and pprof and Profiling.
Conversely, the classic value-semantics performance bug is a method with a value receiver on a struct that grew over time. A func (c Config) Get(...) that was cheap when Config had three fields becomes a silent memmove of hundreds of bytes per call once Config accretes thirty fields. The fix — switching to a pointer receiver — is mechanical, but the bug is invisible until profiled, which is why teams adopt the convention of pointer receivers for any struct that is “non-trivially sized” up front.
A correctness incident pattern worth internalizing: shared mutable state introduced by an accidental shallow copy. A worker pool that copies a job struct per worker, where the job struct embeds a []error accumulator, ends up with every worker appending into — or racing on — the same slice backing array. The symptom is corrupted or lost errors under load and a clean race-detector report pointing at the slice. The fix is either deep-copying the accumulator or, better, redesigning so each worker owns an independent value.
See Also
- Pointer Types in Go — the mechanics of
*T, addressability, andnil - Stack vs Heap Allocation — value semantics is what keeps data off the heap
- Escape Analysis — taking an address is what makes a value a heap candidate
- Struct Memory Layout and Alignment — what “copying a struct” actually moves
- Slice Internals — the three-word header that copies independently of its backing array
- Map Internals — why a map value is already a reference
- Interface Internals — the two-word interface and value boxing
- Method Sets — how value vs pointer receivers shape the callable method set
- copy Semantics Surprises — the shallow-copy traps in detail
- Go Internals MOC — parent map, Section 4 (Memory Layout and Data Representation)