We often learn that Go parameters are either passed by value or by reference. For example, basic types (ints, bools, etc.), arrays and structs are “by value,” while things like pointers, slices, maps, and channels are often called “by reference.” In practice this terminology can be misleading. In Go, every function parameter is passed by value (i.e. copied). That copy might be a simple value (for an int or an array) or a more complex descriptor (for a slice, string, or map).
Passed by value: numbers, booleans, arrays, structs, etc. (the entire value is copied)
Reference-like types: pointers, strings, slices, maps, channels (the pointer or descriptor is copied)
Let’s walk through some examples to see what really happens.
Passing an Array (By Value)
func do(b [3]int) int {
b[0] = 0
return b[1]
}
func main() {
a := [3]int{1, 2, 3}
v := do(a)
fmt.Println(a, v) // [1 2 3] 2
}
In this example, do takes a fixed-size array b [3]int. When we call do(a), Go makes a copy of a and passes it to do. Inside do, setting b[0] = 0 only affects the copied array, not the original. This is why after calling do, the original a is still [1 2 3]. As the Go blog explains, “when you assign or pass around an array value you will make a copy of its contents.” (If you want to modify the caller’s array, you’d have to pass a pointer to the array, e.g. * [3]int.)
Passing a Map (Pointer-Like Behavior)
func do(m1 map[int]int) {
m1[3] = 1
m1[4] = 3
}
func main() {
m := map[int]int{4: 2}
fmt.Println(m) // map[4:2]
do(m)
fmt.Println(m) // map[3:1 4:3]
}
Here, do takes a map. Internally, a Go map is implemented as a pointer to a runtime hash table structure. When we call do(m), Go copies the map descriptor (which is effectively a pointer) and passes it to do. The code m1[3] = 1 then mutates the underlying hash table. Because the descriptor copy still points to the same map data, the original map m in main sees the changes. In other words, the argument m and the parameter m1 both refer to the same map in memory. As Alex Edwards notes, Go “implements a map as a pointer to a runtime.hmap struct,” so passing a map to a function gives you a copy of that pointer – modifying the map will mutate the original data.
The Slice Puzzle: Append and Capacity
func do(b []int) int {
b = append(b, 4)
b[0] = 0
return b[1]
}
func main() {
a1 := []int{1, 2, 3}
fmt.Printf("len %d cap %d\n", len(a1), cap(a1)) // len 3 cap 3
v1 := do(a1)
fmt.Println(a1, v1) // [1 2 3] 2
}
Many people expect slices to behave like maps in the previous example (with changes showing up outside). But here, after calling do(a1), the slice a1 is still [1 2 3]. What happened? Remember that a slice value in Go is itself a 3-word descriptor: it contains a pointer to an underlying array, a length, and a capacity. In our example, a1 := []int{1,2,3} has length 3 and capacity 3. Inside do, we do b = append(b, 4). Since the capacity of b was only 3, append allocates a new underlying array (with larger capacity), copies the old elements over, and returns a new slice header with that new array pointer. Then b[0] = 0 writes to this new array. Meanwhile, the original slice a1 in main still points to the old array (which was untouched). In short, the append caused a reallocation, so b inside do ended up pointing to a different array than a1 does. The assignment b[0] = 0 did not affect a1’s data. As Alex Edwards explains, the slice returned by append “may or may not point to the same underlying array” depending on capacity. If it allocates a new array, the function’s copy of the slice header is changed without affecting the caller’s slice. This is why a1 remains [1 2 3] even though do returned 0 at index 1 of its (new) slice.
All Parameters Are Passed By Value
The key insight is: Go always copies the function arguments. The phrase “pass-by-value” means every argument is copied. There is no hidden “pass-by-reference” semantics in Go. Even when we passed a pointer, a slice, or a map, the pointer or descriptor was copied by value. As Alex Edwards emphasizes, “parameters are always a copy of the arguments. That is, they are always passed by value.”.
In other words, a function parameter is never an alias for the caller’s variable. The only way a function can change the caller’s variable is if the caller passes in something like a pointer and the function dereferences it to write through to the original data.
Descriptors for Strings, Slices, and Maps
In Go’s runtime, complex types like strings, slices, and maps are built on small header (descriptor) values that contain pointers under the hood. For example, a Go string is a two-word struct: a pointer to the UTF-8 data and a length. A slice is a three-word struct: a pointer to the first element of an array, the slice length, and the slice capacity. A map is not described by just a few words in Go code, but internally it’s a pointer to a runtime hash table. Thus, when you pass a map or channel to a function, you are copying that pointer-like header. Any element writes (like m1[3]=...) update the shared data. Notice that in each case, what gets copied is the header or pointer, not the entire contents. So when we say slices or maps are “reference types,” we mean their headers contain pointers to shared data. But the headers themselves are passed by value (copied) into functions.
Passing a Pointer to a Slice
func do(b *[]int) int {
*b = append(*b, 4)
(*b)[0] = 0
return (*b)[1]
}
func main() {
a1 := []int{1, 2, 3}
fmt.Printf("len %d cap %d\n", len(a1), cap(a1)) // len 3 cap 3
v1 := do(&a1)
fmt.Println(a1, v1) // [0 2 3 4] 2
}
What if we really want the function to be able to grow the caller’s slice? Here do takes a pointer to a slice (*[]int). Now, inside do we do *b = append(*b, 4) – this dereferences the pointer and reassigns the caller’s slice variable to the new slice value returned by append. Then (*b)[0] = 0 writes to the shared array. When we run this, the output shows a1 has become [0 2 3 4]. By passing &a1, we gave do the address of the slice header. Any assignment to *b actually updates a1 in main. As the official blog notes, writing *s = append(*s, …) will be “written-through to the memory address of the scores argument”. In other words, using a pointer-to-slice parameter forces append and other reassignments to affect the original slice variable.
Conclusion
To summarize:
Go always passes function arguments by value. Even a pointer, slice, map or channel parameter is still a copy of whatever header or pointer you passed in. There is no hidden pass-by-reference mechanism.
Basic types, arrays, structs: Passing them copies the entire value. Mutating the parameter never changes the caller’s variable unless you passed a pointer to it.
Slices, maps, channels: Each has an internal header (pointer + length/capacity or pointer to a hash table). Passing one of these copies the header. Mutations to the underlying data (e.g. setting a slice element or a map entry) affect the original data structure. However, reassigning the slice or map (like b = append(b,…) or m = make(map…)) only changes the local copy of the header, not the caller’s copy.
Strings: Immutable, but a string header (pointer + length) is also passed by value. Slicing a string creates a new header pointing into the same data.
In short, don’t think in terms of “Go passes this by reference.” Think of it as “Go copies values,” and the effect depends on what those values are. If the value contains a pointer to shared data (as in slices or maps), then functions can mutate that shared data. If you need a function to update a variable itself (like extend a slice or change a struct), you must pass a pointer to it. This understanding will help avoid confusing surprises and make your Go code behavior predictable.
Top comments (0)