Ever faced a situation where modifying a slice inside a helper function updates the original data sometimes, but appending mysteriously does nothing?
Welcome to the Go slice paradox.
The Confusing Scenario
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)
}
// Alice: 40 - Wait, this changed!
// Bob: 30
// Charlie: ? - Where did Charlie go?!
}
func updateUsers(users []*User) {
// This change persists
users[0].Age = 40
// This change disappears
users = append(users, &User{Name: "Charlie", Age: 35})
}
Why does Alice update but Charlie disappears into the void?
Let’s unpack the internals.
Understanding What’s Inside a Slice
A Go slice is not an array.
It is a tiny 3-field header:
- Pointer to underlying array
- Length
- Capacity
// Conceptual slice header
type sliceHeader struct {
pointer *array
length int
capacity int
}
When passed to a function, this header is copied, not the underlying data.
The Pointer Double Whammy in []*User
With []*User, there are two indirection layers:
- Slice header → points to array
- Array elements → pointers to user structs
users (slice header)
│
├──→ [pointer1, pointer2] (array of pointers)
│ │ │
│ │ └──→ Bob struct
│ │
│ └──→ Alice struct
│
└── length: 2, capacity: 2
Rules of Engagement
Rule 1: Struct modifications persist
func updateUsers(users []*User) {
users[0].Age = 40 // Updates original struct
}
Why?
Because both caller and callee share the same pointer to Alice.
Rule 2: Slice appends vanish
func updateUsers(users []*User) {
users = append(users, &User{Name: "Charlie"})
}
append may allocate a new array and produces a new slice header, but only inside the function.
The caller still points to the old slice.
Visualization
Before function call
main.users → [ptr1, ptr2] (len=2, cap=2)
Inside function after append
updateUsers.users → [ptr1, ptr2, ptr3] (len=3, cap=4)
main.users → unchanged
After function returns
Charlie → eligible for GC
Fixing Appends (Make Them Persist)
Option 1: Pass a pointer to the slice
func main() {
users := []*User{...}
updateUsers(&users)
}
func updateUsers(users *[]*User) {
*users = append(*users, &User{Name: "Charlie"})
}
Option 2: Return the updated slice
func main() {
users := []*User{...}
users = updateUsers(users)
}
func updateUsers(users []*User) []*User {
users[0].Age = 40
return append(users, &User{Name: "Charlie"})
}
Option 3: Use methods on custom types
type UserList []*User
func (ul *UserList) AddUser(name string, age int) {
*ul = append(*ul, &User{Name: name, Age: age})
}
func main() {
users := UserList{...}
users.AddUser("Charlie", 35)
}
Key Takeaways
- When you update the fields of a struct through a pointer inside a slice, the changes are reflected in the original data because both the caller and the callee reference the same memory.
- When you modify the slice itself (length or capacity), those changes do not carry over because the slice header is passed by value.
-
As a result, using
appendinside a function won’t affect the caller’s slice unless:- you return the modified slice, or
- you pass a pointer to the slice.
The Mantra
Pointers in slices share memory, but slice headers don’t.
If you grow a slice in another function, pass a pointer or return it.
If you found this useful, share it with a Go dev who’s currently yelling at their debugger 😉
Top comments (0)