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

Published: (December 13, 2025 at 02:09 PM EST)
3 min read
Source: Dev.to

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.
Back to Blog

Related posts

Read more »

GO profiling using pprof

What is pprof? pprof is Go's built‑in profiling tool that allows you to collect and analyze runtime data from your application, such as CPU usage, memory alloc...