The hidden risk of passing slice as function parameter

Summary

When passing a Go slice to a function, a copy of its header—containing the data pointer, length, and capacity—is made. Direct modifications to existing slice elements will reflect outside the function because the underlying array is shared. However, operations like append that cause the slice to exceed its capacity will trigger a reallocation, creating a new underlying array for the local slice copy. This breaks the connection to the original slice, meaning structural changes like a new backing array or updated length/capacity will not propagate back to the caller. To ensure all modifications, including reallocations, are visible to the caller, it is necessary to pass a pointer to the slice (*[]T) as a function parameter.

In Go's source code or other open source libraries, there are lots of cases where a slice pointer is passed to function instead of slice itself. This brings up a doubt why not passing slice directly as its internal is backed by an array pointer to point to underlying data?

For example, in log package, the formatHeader function takes a parameter buf as type *[]byte instead of []byte.

func (l *Logger) formatHeader(buf *[]byte, t time.Time, file string, line int) {}

Let's understand the rationale behind this starting with an example.

func modifySlice(innerSlice []string) {
    innerSlice[0] = "b"
    innerSlice[1] = "b"
    fmt.Println(innerSlice)
}

func main() {
    outerSlice := []string{"a", "a"}
    modifySlice(outerSlice)
    fmt.Print(outerSlice)
}

// output
[b b]
[b b]

The above output is expected as the underlying data pointer is updated which reflects both inside and outside the function.

Now changing the parameter of the function to take a pointer to the slice

func modifySlice(innerSlice *[]string) {
    (*innerSlice)[0] = "b"
    (*innerSlice)[1] = "b"
    fmt.Println(*innerSlice)
}

func main() {
    outerSlice := []string{"a", "a"}
    modifySlice(&outerSlice)
    fmt.Print(outerSlice)
}

// output
[b b]
[b b]

From the output, the result is the same. Hence what's the difference? Take a closer look at the struct of slice.

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

array is the internal array pointer stores the actual data, len is the length of the slice and cap is the capacity of the slice

Modify the above code snippet a bit.

func modifySlice(innerSlice []string) {
    innerSlice = append(innerSlice, "a")
    innerSlice[0] = "b"
    innerSlice[1] = "b"
    fmt.Println(innerSlice)
}

func main() {
    outerSlice := []string{"a", "a"}
    modifySlice(outerSlice)
    fmt.Print(outerSlice)
}

// output
[b b a]
[b b]

Alright, the result is a bit interesting now. The update to the slice inside the function doesn't reflect outside of the function. What happened?

To figure this out, adding more logs to see what is going on.

func modifySlice(innerSlice []string) {
    fmt.Printf("%p %v   %p\n", &innerSlice, innerSlice, &innerSlice[0])
    innerSlice = append(innerSlice, "a")
    innerSlice[0] = "b"
    innerSlice[1] = "b"
    fmt.Printf("%p %v %p\n", &innerSlice, innerSlice, &innerSlice[0])
}

func main() {
    outerSlice := []string{"a", "a"}
    fmt.Printf("%p %v   %p\n", &outerSlice, outerSlice, &outerSlice[0])
    modifySlice(outerSlice)
    fmt.Printf("%p %v   %p\n", &outerSlice, outerSlice, &outerSlice[0])
}

// output
0xc00000c060 [a a]   0xc00000c080
0xc00000c0c0 [a a]   0xc00000c080
0xc00000c0c0 [b b a] 0xc000022080
0xc00000c060 [a a]   0xc00000c080

Parameters passed in Go functions are values. When passing a slice into a function, a copy of the slice struct is passed. Hence the underlying array pointer is copied as well, if the data changed in array pointer, it would reflect on the copied slice struct as well.

However if the underlying array pointer is resized or reallocated, the change to the inside slice would not reflect on the outside slice. The same applies to len and cap.

Below are some diagrams explaining this.

If len and cap is the same, an append operation would trigger resizing,  when resing happens, a new underlying array is created with new size, the data from original array would be copied to the new array. And the new appended item would be in the new array. Now the link between the slice inside the function and the outside function is broken. 

 This explains why some functions would take pointers of slice as parameters.

func modifySlice(innerSlice *[]string) {
    *innerSlice = append(*innerSlice, "a")
    (*innerSlice)[0] = "b"
    (*innerSlice)[1] = "b"
    fmt.Println(*innerSlice)
}

func main() {
    outerSlice := []string{"a", "a"}
    modifySlice(&outerSlice)
    fmt.Print(outerSlice)
}

// output
[b b a]
[b b a]

The lesson takeaway is that if just updating the value of slice without appending or other operation which would cause resizing, can pass slice, otherwise please consider to pass pointer of slice.

Reference: Go 切片传递的隐藏危机

GOLANG SLICE SLICE POINTER

  RELATED

  COMMENTS

3
Anonymous
Dec 14, 2020 at 9:37 pm

image is broken

Ke Pi
Dec 15, 2020 at 5:39 am

thx for pointing out. fixed

Anonymous
Mar 27, 2025 at 9:50 am

It is very helpful. Thank you.