Unpacking Go Slices: 3 Common Gotchas You Need to Know



This content originally appeared on DEV Community and was authored by Mahdi

Go’s slices are a cornerstone of the language, offering a powerful and flexible way to work with sequences of data. However, their internal workings can lead to some surprising behavior if you’re not careful. The common phrase “slices are passed by reference” is a helpful simplification, but it’s not the whole story and can lead you into traps.

Let’s dive into three common “gotchas” that every Go developer should understand to write safer, more predictable code.

1. The append Pitfall: Slices are Passed by Value

It’s a common misconception that passing a slice to a function allows the function to modify the original slice freely. Let’s test this with an append operation.

You might expect this code to print [string string2]:

package main

import "fmt"

func update(s []string) {
    s = append(s, "string2") 
}

func main() {
    s := make([]string, 1, 1) 
    s[0] = "string"

    update(s)

    fmt.Println(s) // Output: [string]
}

The Gotcha

The output is [string], not [string string2]. Why?
A slice isn’t a direct pointer; it’s a small descriptor object called a slice header. This header contains three fields:

1- A pointer to an underlying array where the data is stored.

2- The length of the slice.

3- The capacity of the slice (the total size of the underlying array).

When you pass a slice to a function, you are passing a copy of this header.

In our update function, append sees that the slice’s capacity is full (both length and capacity are 1). To add a new element, it must perform a reallocation: it creates a new, larger underlying array and copies the old elements over. The local copy of the slice header s inside update is modified to point to this new array, but the original slice header back in main remains unchanged and still points to the old, smaller array.

The Fix

To make the changes visible to the caller, you must return the (potentially new) slice from the function.

package main

import "fmt"

func update(s []string) []string {
    s = append(s, "string2")
    return s
}

func main() {
    s := make([]string, 1, 1)
    s[0] = "string"

    s = update(s) 

    fmt.Println(s) // Output: [string string2]
}

Key Takeaway:

When a function modifies a slice’s length or capacity (e.g., with append), it should always return the modified slice.

2. The Stale Pointer: Risky Business with Element Addresses

Taking a pointer to an element within a slice can be risky if the slice’s length changes.

Consider this example where we get a pointer to the first user, alice, and then append a new user to the slice.

package main

import "fmt"

type user struct {
    name  string
    count int
}

func addTo(u *user) {
    u.count++
}

func main() {
    users := []user{{"alice", 0}, {"bob", 0}} // Capacity is 2

    alice := &users[0]

    users = append(users, user{"amy", 0})

    addTo(alice)

    fmt.Println(users) // Output: [{alice 0} {bob 0} {amy 0}]
    fmt.Println(*alice) // Output: {alice 1}
}

The Gotcha

The final users slice shows Alice’s count is still 0. The addTo(alice) call modified a version of “alice” that is no longer part of the slice!

Just like in our first example, the append operation caused a reallocation. A new, larger array was created to hold alice, bob, and amy. The users slice variable was updated to point to this new array.

However, our alice pointer is now a stale pointer—it still points to the memory location in the original backing array, which is no longer referenced by the users slice.

Key Takeaway

Avoid holding pointers to slice elements if the slice might be reallocated by operations like append. It’s often safer to work with indices.

3. The range Loop Trap: Capturing the Wrong Thing

This is one of the most common gotchas for new Go programmers. When you use a for…range loop, Go uses a single variable to hold the value for each iteration.

// The `thing` variable is reused in each iteration.
for i, thing := range things {
    // thing is a copy of things[i]
}

This reuse can cause chaos when you try to capture a slice or pointer from the iteration variable.

package main

import "fmt"

func main() {
    items := [][2]byte{{1, 2}, {3, 4}, {5, 6}, {7, 8}}
    a := [][]byte{}

    for _, item := range items {
        a = append(a, item[:])
    }

    fmt.Println("Original:", items)
    fmt.Println("Result:", a)
}

Expected Output for a: [[1 2] [3 4] [5 6] [7 8]]
Actual Output for a: [[7 8] [7 8] [7 8] [7 8]]

The Gotcha

The item variable is a single [2]byte array in memory that gets overwritten on each loop. The expression item[:] creates a slice header that points to that memory. Each time we append, we are appending a slice that points to the same memory location. By the time the loop finishes, that memory holds the last value from items, which is {7, 8}. All the slices we appended into a now point to this final value.

The Fix

You must create a true copy of the data for each iteration.

package main

import "fmt"

func main() {
    items := [][2]byte{{1, 2}, {3, 4}, {5, 6}, {7, 8}}
    a := [][]byte{}

    for _, item := range items {
        i := make([]byte, len(item))
        copy(i, item[:])
        a = append(a, i)
    }

    fmt.Println("Original:", items)
    fmt.Println("Result:", a)
}

Expected Output for a: [[1 2] [3 4] [5 6] [7 8]]
Actual Output for a: [[7 8] [7 8] [7 8] [7 8]]

Key Takeaway

When creating slices or pointers from a range loop variable for later use, make sure you are capturing a fresh copy of the data, not a reference to the temporary loop variable itself.

References

Go Slices: Sharing is Caring, Until You Run Out of Room

Parameter Passing in Golang: The Ultimate Truth

Go Class: 14 Reference & Value Semantics


This content originally appeared on DEV Community and was authored by Mahdi