Explain slice internals: what happens when you append past capacity?

5 minintermediategoslicesappendmemory

Quick Answer

A slice's header holds a pointer to a backing array, a length, and a capacity. When append is called and there's still room (len < cap), it writes the new element directly into the existing backing array and returns a slice header with len+1. When there's no room (len == cap), append allocates a new, larger backing array, copies every existing element into it, appends the new element, and returns a slice header pointing at this new array — the old backing array is left untouched (and eventually garbage collected if nothing else references it). Go's growth strategy roughly doubles capacity for smaller slices and uses a smaller growth factor for larger ones, to balance wasted memory against the cost of frequent reallocation.

Detailed Answer

Understanding exactly when append reallocates explains most of the "surprising" slice-sharing bugs people run into.

Appending within capacity: no reallocation

s := make([]int, 3, 5)  // len=3, cap=5
s2 := append(s, 4)       // room available: writes into the same backing array
fmt.Println(&s[0] == &s2[0])  // true — same underlying array

Appending past capacity: reallocation

s := make([]int, 3, 3)  // len=3, cap=3 — no room left
s2 := append(s, 4)       // must allocate a new, larger backing array
fmt.Println(&s[0] == &s2[0])  // false — s2 now points at a completely different array

After this reallocation, s and s2 are fully independent. Modifying one no longer affects the other, which is the opposite of what happens when there's still capacity.

Why this causes subtle bugs

func addItem(items []int, v int) []int {
    return append(items, v)
}

original := make([]int, 2, 5)
a := addItem(original, 10)   // room available: writes into original's backing array
b := addItem(original, 20)   // ALSO writes into the same slot — overwrites a's new element!
fmt.Println(a)  // [0 0 20] — not 10!

Both calls append at index 2 of the same backing array, since original still had spare capacity both times. The second call silently overwrites what the first call wrote, since they never triggered a reallocation to separate them.

Growth strategy, approximately

Small slices (below a runtime-defined threshold): capacity roughly doubles
Larger slices: growth factor decreases toward about 1.25x, to avoid excessive memory waste

The safe pattern when independence matters

result := make([]int, len(original), len(original)+1)
copy(result, original)
result = append(result, v)  // guaranteed to be independent of `original`

Explicitly copying into a right-sized new slice avoids relying on capacity behavior you don't control at the call site.