Alright, let’s get our hands dirty with append(). This is where the rubber meets the road and where most new Go developers get their first, confusing flat tire. The name makes it sound so simple: “just add this to the end.” And it is… until it isn’t. The magic—and the occasional horror—happens under the hood with the backing array.

Think of a slice not as the data itself, but as a fancy struct with a pointer to an array, a length (how much of the array it’s using), and a capacity (how much of the array it could use). When you append, you’re asking to add an element to the end of the used portion. If there’s room in the capacity (len(s) < cap(s)), append just drops the new value in the next available slot, bumps the length, and hands you back the same slice, now with a new length. It’s fast and cheap.

mySlice := make([]int, 2, 4) // len:2, cap:4. Underlying array: [0, 0, ?, ?]
mySlice[0] = 42
mySlice[1] = 101
// mySlice is [42, 101], but the backing array is [42, 101, 0, 0]

mySlice = append(mySlice, 777)
// len is now 3. Backing array has room, so it becomes [42, 101, 777, 0]
// mySlice is still pointing to the SAME array. No big deal.

The Great Array Exodus

Here’s where the fun begins. What if the slice is already at capacity? There’s no more room at the inn. The append function can’t just force a new element into the existing array; it’s a fixed size. So, it does the only sensible thing: it gets a bigger apartment.

It creates a new backing array, typically with double the capacity of the old one (the exact growth algorithm is a implementation detail, but it’s designed to amortize the cost of these copies over many appends). It then copies all the existing elements from the old array into the new one, slaps the new value on the end, and returns a brand new slice descriptor that points to this new, fancier array.

smallSlice := []string{"alice", "bob"} // len:2, cap:2. No room left.
fmt.Printf("Before append: %p\n", &smallSlice[0]) // Print address of first element

biggerSlice := append(smallSlice, "charlie")
fmt.Printf("After append:  %p\n", &biggerSlice[0]) // Different address!

fmt.Println(smallSlice)   // [alice bob]
fmt.Println(biggerSlice) // [alice bob charlie]

The key takeaway: append may or may not return a slice with a different backing array. This isn’t just academic; it’s the root cause of some of the most common and frustrating bugs in Go.

The Shared Array Trap

This behavior leads directly to the classic “I just modified two slices at once!” pitfall. Let’s set the stage. You slice an existing slice, and then you append to the original.

a := []int{1, 2, 3, 4} // len:4, cap:4
b := a[:2]              // b: [1, 2], but it shares the SAME backing array as 'a'
fmt.Println(a, b)       // [1 2 3 4] [1 2]

// Now append to 'b'. Is there capacity? b's cap is 4 (the whole array), len is 2.
// So yes, there's room. The append happens in-place.
b = append(b, 999)

// We just overwrote a[2] because 'b' is using the first half of 'a's array.
fmt.Println(a) // [1 2 999 4]  <-- Wait, what?!
fmt.Println(b) // [1 2 999]

Surprise! You mutated a by appending to b. This is not a bug in Go; it’s a direct consequence of how slices are designed. It’s a feature, albeit a sharp one you can easily cut yourself on. The fix, when you absolutely need to decouple two slices, is to use a full slice expression or an explicit copy.

append and Zero Values

A subtler point: append always writes to the underlying array. If you append to a slice that has a capacity greater than its length, it will overwrite whatever existing values are in those unused slots. This is usually fine, as those slots are supposed to be unused. But it’s a good reminder that the capacity isn’t just empty space; it’s a part of an initialized array.

// Let's see what's in those unused slots
arr := [4]int{100, 200, 300, 400} // A fixed array
s := arr[:2]                       // Slice it: s is [100, 200], cap is 4
fmt.Println(arr)                   // [100 200 300 400]

s = append(s, 999)                // We have capacity, so we overwrite arr[2]
fmt.Println(arr)                   // [100 200 999 400] <- 300 got clobbered

The value 300 wasn’t “garbage”; it was a real value in the backing array that append had every right to overwrite. This is why sharing backing arrays requires careful thought.

The Three-Index Slice to the Rescue

So how do you avoid accidentally sharing an array when you slice? You use the rarely-seen but incredibly useful three-index slice expression. It lets you specify not just the start and end index, but also the capacity of the new slice.

The syntax is a[low:high:max]. This creates a slice with:

  • length = high - low
  • capacity = max - low

The magic is that it sets the capacity exactly, preventing the new slice from seeing any elements in the original array beyond the max index. This means the next append will force a copy.

a := []int{1, 2, 3, 4, 5}
b := a[:2:2] // b: [1, 2], len:2, cap:2. Capacity is truncated.

// Now 'b' has no extra capacity. An append MUST create a new array.
b = append(b, 999)

fmt.Println(a) // [1 2 3 4 5] <- Unchanged! Glory!
fmt.Println(b) // [1 2 999]

This is your best friend when you want to take a subset of a slice and guarantee that future appends won’t corrupt the original. It’s a clear signal to anyone reading your code: “I am deliberately limiting this slice’s capacity to avoid aliasing.” It’s a pro move. Use it.