DEV Community

Moksh
Moksh

Posted on

Go Slices: The Pointer Paradox Why Your Appends Disappear (Understanding when slice modifications persist and when they vanish)

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})
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Slice header → points to array
  2. Array elements → pointers to user structs
users (slice header)
    │
    ├──→ [pointer1, pointer2] (array of pointers)
    │        │          │
    │        │          └──→ Bob struct
    │        │
    │        └──→ Alice struct
    │
    └── length: 2, capacity: 2
Enter fullscreen mode Exit fullscreen mode

Rules of Engagement

Rule 1: Struct modifications persist

func updateUsers(users []*User) {
    users[0].Age = 40 // Updates original struct
}
Enter fullscreen mode Exit fullscreen mode

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"})
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Inside function after append

updateUsers.users → [ptr1, ptr2, ptr3] (len=3, cap=4)
main.users        → unchanged
Enter fullscreen mode Exit fullscreen mode

After function returns

Charlie → eligible for GC
Enter fullscreen mode Exit fullscreen mode

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"})
}
Enter fullscreen mode Exit fullscreen mode

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"})
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 append inside 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)