Go Slices: The Pointer Paradox Why Your Appends Disappear (Understanding when slice modifications persist and when they vanish)
Source: Dev.to
func main() {
users := []*User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
updateUsers(users)
fmt.Println("After update:")
for _, u := range users {
fmt.Printf("%s: %d\n", u.Name, u.Age)
}
// Output:
// Alice: 40 - changed
// Bob: 30
// Charlie: ? - missing
}
func updateUsers(users []*User) {
// This change persists
users[0].Age = 40
// This change disappears
users = append(users, &User{Name: "Charlie", Age: 35})
}
Why does the first change stick while the appended element vanishes?
The slice header
A Go slice is not an array. Internally it consists of a three‑field header:
// Conceptual slice header
type sliceHeader struct {
pointer *array // points to the underlying array
length int // number of elements in the slice
capacity int // size of the underlying array
}
When a slice is passed to a function, the header is copied; the underlying array is not copied.
Two levels of indirection with []*User
users (slice header)
│
├──→ [ptr1, ptr2] (array of pointers)
│ │ │
│ │ └──→ Bob struct
│ │
│ └──→ Alice struct
│
└── length: 2, capacity: 2
Both the caller and the callee share the same pointer to the underlying array, so modifying the struct that a pointer refers to (e.g., users[0].Age = 40) updates the original data.
What happens on append
append may allocate a new array when the existing capacity is insufficient. It then creates a new slice header that points to this new array. Because the header was passed by value, only the callee sees the new header; the caller continues to hold the original header.
Before call (in main):
users → [ptr1, ptr2] (len=2, cap=2)
Inside updateUsers after append:
users → [ptr1, ptr2, ptr3] (len=3, cap=4) // new header, possibly new array
After return:
main.users is unchanged → still length 2
ptr3 (Charlie) becomes unreachable → eligible for GC
Making the append visible to the caller
1. Return the new slice
func updateUsers(users []*User) []*User {
users[0].Age = 40
return append(users, &User{Name: "Charlie", Age: 35})
}
func main() {
users := []*User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
users = updateUsers(users)
// users now contains Charlie
}
2. Pass a pointer to the slice
func updateUsers(users *[]*User) {
(*users)[0].Age = 40
*users = append(*users, &User{Name: "Charlie", Age: 35})
}
func main() {
users := []*User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
updateUsers(&users)
// users now contains Charlie
}
3. Use a method with a pointer receiver
type UserList []*User
func (ul *UserList) AddUser(name string, age int) {
*ul = append(*ul, &User{Name: name, Age: age})
}
func main() {
users := UserList{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
}
users.AddUser("Charlie", 35)
// users now contains Charlie
}
Key takeaways
- Field updates through pointers inside a slice affect the original data because the underlying array is shared.
- Slice modifications (changing length or capacity) do not propagate unless the new header is returned or a pointer to the slice is passed.
- Think of a slice as a value containing a pointer, length, and capacity; the pointer part is shared, the header itself is not.