Slice Aliasing and append Surprises

A Go slice is a three-word header — a pointer to a backing array, a length, and a capacity — and that header is copied by value while the backing array it points at is not. Two slices can therefore alias the same array: a write through one is visible through the other. Worse, append behaves in two opposite ways depending on a runtime condition you cannot see in the source — if there is spare capacity it mutates the shared array in place, and if there is not it allocates a fresh array and silently stops aliasing. The same line of code can corrupt a caller’s data on one input and be perfectly safe on the next. The slice blog (go.dev/blog/slices) and the spec’s append rules (go.dev/ref/spec) define the mechanism precisely.

This is a focused gotcha note. The full anatomy of the slice header, growth policy, and make semantics lives in Slice Internals; here we assume that header and trace why its sharing semantics surprise people.

Mental Model

Hold two facts in mind simultaneously and every “surprise” dissolves.

Fact one — the header is a value, the array is shared. When you assign a slice, pass it to a function, or re-slice it, you copy the three-word header {ptr, len, cap}. You do not copy the backing array. So s2 := s1 gives s2 an independent length and capacity that it can change without affecting s1 — but both ptr fields point at the same array, so s2[0] = 99 is visible as s1[0].

Fact two — append is conditional. append(s, x) asks: does s have room (len(s) < cap(s))? If yes, it writes x into the existing backing array at index len(s) and returns a header with len+1. If no, it allocates a new, larger array, copies the old elements in, writes x, and returns a header pointing at the new array. The first branch keeps the alias and mutates shared memory; the second branch breaks the alias. Which branch runs is invisible at the call site.

flowchart TD
    S["s = make([]int,3,5)  ->  header {ptr, len=3, cap=5}"]
    S --> Q{"append(s, 9):<br/>len &lt; cap ?"}
    Q -->|"YES (3 &lt; 5)"| IN["write into existing array at idx 3<br/>return {ptr (SAME), len=4, cap=5}<br/>== aliases survive, shared memory mutated"]
    Q -->|"NO (len == cap)"| RE["malloc new bigger array<br/>copy old elements<br/>return {ptr (NEW), len=4, cap=~8}<br/>== alias broken, original untouched"]
    style IN fill:#7f1d1d,color:#fff
    style RE fill:#1b5e20,color:#fff

Diagram: the single branch inside append that explains every slice-aliasing bug. The dangerous branch is the in-place one (red): it silently writes through a pointer the caller may still be holding. The reallocating branch (green) is “safe” only by accident — it stops sharing. Because the branch depends on a runtime len/cap relationship, the same source line takes different branches on different inputs.

Mechanical Walk-through

The header and what [low:high] produces

The spec defines a slice header conceptually as length, capacity, and a pointer to the first element (go.dev/blog/slices gives the explicit struct). A simple slice expression a[low:high] produces a new header: length high - low, and — critically — capacity that extends to the end of the underlying array, i.e. cap(a) - low, not high - low (go.dev/ref/spec). This is the root of most surprises: a sub-slice b := a[1:3] of a length-5 array has length 2 but capacity 4. b therefore has spare capacity that physically overlaps a[3] and a[4]. An append to b will write straight into a’s elements.

The full slice expression a[low:high:max] (the three-index form) caps capacity at max - low. This is the only in-language tool to deliberately limit a sub-slice’s capacity and force the next append to reallocate.

What append does, step by step

Per the spec (go.dev/ref/spec): “If the capacity of s is not large enough to fit the additional values, append allocates a new, sufficiently large underlying array that fits both the existing slice elements and the additional values. Otherwise, append re-uses the underlying array.” Concretely, append(s, elems...):

  1. Compute need = len(s) + len(elems).
  2. If need <= cap(s): write elems into s’s backing array starting at index len(s). Return {s.ptr, need, s.cap} — same pointer, longer length. Any other slice aliasing that array, and overlapping the indices [len(s), need), sees its elements overwritten.
  3. If need > cap(s): allocate a new array (size chosen by the growth policy — roughly doubling for small slices, ~1.25× for large ones; the exact policy is Slice Internals’s concern). Copy s’s len(s) elements into it, then write elems. Return {newPtr, need, newCap} — a header that no longer shares with the original.

The exact size of the new array is not specified — only that it is “sufficiently large.” Code must never assume a particular post-append capacity.

Why “save the return value” is mandatory

Because branch 3 returns a different pointer, the only correct way to call append is s = append(s, x). If you wrote append(s, x) and discarded the result, then on the realloc branch your s still points at the old, shorter array and you lose the appended element; on the in-place branch you also lose the length increment. The compiler does not require you to use the result (the spec treats append as an ordinary expression), so this is a discipline rule, not an enforced one. (go vet does flag the obvious append(s, x)-with-discarded-result case.)

Code Examples with Line-by-Line Commentary

Example 1 — the same line, two outcomes

package main
 
import "fmt"
 
func main() {
    base := []int{0, 1, 2, 3, 4}     // len 5, cap 5
    a := base[0:3]                   // len 3, cap 5  -- shares base, has spare cap
    b := append(a, 99)               // need=4 <= cap=5 -> IN PLACE
    fmt.Println(base)                // [0 1 2 99 4]   -- base[3] was overwritten!
    fmt.Println(b)                   // [0 1 2 99]
    _ = b
}

Line 7: a is base[0:3] — length 3 but capacity 5, because [low:high] extends capacity to the end of base. Line 8: append(a, 99) needs 4 slots, cap(a) is 5, so it takes the in-place branch and writes 99 into base’s backing array at index 3 — silently corrupting base[3], which was 3. Line 9 prints [0 1 2 99 4]. The programmer thought they were building a new slice b; they mutated base.

Now change one number — make base length 3:

    base := []int{0, 1, 2}           // len 3, cap 3
    a := base[0:3]                   // len 3, cap 3  -- NO spare capacity
    b := append(a, 99)               // need=4 > cap=3 -> REALLOCATE
    fmt.Println(base)                // [0 1 2]       -- untouched
    fmt.Println(b)                   // [0 1 2 99]    -- fresh array

Identical append line; opposite behavior. With cap(a) == 3 there is no room, append reallocates, and base is left alone. A unit test on the second input would pass and give false confidence; the first input is the production bug.

Example 2 — the function-boundary surprise

func addItem(items []string) []string {
    return append(items, "new")      // mutates caller's array IF it has spare cap
}
 
func main() {
    all := make([]string, 3, 10)     // len 3, cap 10  -- lots of spare capacity
    all[0], all[1], all[2] = "a", "b", "c"
    x := addItem(all)                // appends into all's backing array, in place
    y := addItem(all)                // appends AGAIN at index 3 -- overwrites!
    fmt.Println(x)                   // [a b c new]
    fmt.Println(y)                   // [a b c new]   -- not [a b c new new]; aliased
    fmt.Println(all)                 // [a b c]       -- len unchanged, but [3] mutated
}

addItem receives a copy of the header — so it cannot change all’s length. But the header copy still points at all’s backing array. With cap 10 there is room, so each append writes in place at index 3. Both x and y alias the same memory and the same index; the second call overwrites the first. The caller’s all reports length 3 (its header was never updated) yet its underlying all[3] slot has been written. This is the most insidious form — a function that “looks pure” mutates shared state depending on the caller’s capacity.

Example 3 — re-slicing shares; append then aliases

data := []int{10, 20, 30, 40, 50}
left  := data[0:2]                   // len 2, cap 5
right := data[2:5]                   // len 3, cap 3
left = append(left, 999)             // need=3 <= cap=5 -> in place at index 2
fmt.Println(right[0])                // 999  -- right[0] IS data[2], just clobbered

left and right are disjoint views but share one array. append(left, 999) writes at data[2], which is exactly right[0]. Slicing gave the illusion of two independent slices; append revealed they were never independent.

Example 4 — the three-index slice as the fix

left := data[0:2:2]                  // FULL slice expr: len 2, cap 2 (capped!)
left = append(left, 999)             // need=3 > cap=2 -> REALLOCATE
fmt.Println(data[2])                 // 30  -- untouched; alias was severed deliberately

data[0:2:2] uses the three-index form [low:high:max], setting capacity to max-low == 2. Now left has no spare capacity, the next append is forced to reallocate, and the alias is broken on purpose. This is the canonical defensive pattern for “I am handing out a sub-slice and I do not want the recipient’s append to reach back into my array.”

Failure Modes and How to Diagnose Them

Symptom: a function mutated data its caller still holds. The function did append(arg, ...) (or re-sliced then appended) and the caller’s slice had spare capacity. Diagnose by printing cap() at the call site — if cap > len, an in-place append is possible.

Symptom: two slices that “should be independent” change together. They were produced by slicing the same array (or one from the other), and one was append-ed within capacity. The aliasing was created at the slice expression, not the append; append only made it visible.

Symptom: data lost after append. The return value of append was discarded — append(s, x) instead of s = append(s, x). On a realloc this loses the new element entirely. go vet’s analysis flags the simplest version of this.

Symptom: a slice retains a giant array and the GC cannot reclaim it. small := big[0:1] keeps the entire backing array of big alive because the slice header still points into it — capacity reaches the end of big. The fix is to copy: small := append([]T(nil), big[0:1]...) or copy. This is a memory-leak variant of aliasing; see Stack vs Heap Allocation and Go Garbage Collector.

The race-detector angle. Two goroutines that each append to slices aliasing the same array, both taking the in-place branch, write to the same memory without synchronization — a data race. The race detector will catch it; the symptom is a flagged write-write race on the backing array. Go 1.26 added no slice changes that affect this (go.dev/doc/go1.26).

Common Misunderstandings

“Passing a slice copies it, so the function can’t change my data.” It copies the header, not the array. Length changes are isolated; element writes and in-place appends are not.

append always returns a new slice.” Only when it reallocates. Within capacity it returns a header pointing at the same array — that is the dangerous case.

b := a[1:3] gives b capacity 2.” No — capacity extends to the end of a’s backing array. Use the three-index form a[1:3:3] to actually get capacity 2.

copy and append are interchangeable for duplicating a slice.” copy(dst, src) copies into a pre-existing dst array and never aliases or reallocates; append([]T(nil), src...) allocates a fresh array. To defensively duplicate, both work; to grow, only append does.

Alternatives and When to Choose Them

Three-index slicing s[low:high:max] — when you hand out a sub-slice and want to guarantee the recipient’s append cannot reach your array. Caps capacity so the next append must reallocate.

Explicit copy — dst := make([]T, len(src)); copy(dst, src) — when you need a genuinely independent slice up front. Predictable, no shared array, no surprise branch. Choose this when correctness matters more than the one allocation.

append([]T(nil), src...) — a one-liner clone; allocates a right-sized fresh array. Equivalent to the make+copy pair, terser, idiomatic for “snapshot this slice.”

slices.Clone (standard library slices package, since Go 1.21) — the modern, intent-revealing clone. Prefer it over hand-rolled append([]T(nil), ...) for readability.

Document capacity contracts — if you intend in-place append (e.g. a buffer-reuse pattern with sync.Pool), say so and own the aliasing. The gotcha is accidental aliasing; deliberate buffer reuse is a legitimate, fast pattern. See sync.Pool Internals.

Production Notes

The slice blog calls saving append’s return value “critical,” and the second slice intro blog frames the whole append/alias model as the thing every Go programmer must internalize (go.dev/blog/slices). In real systems the bug surfaces most often in two shapes. First, the shared-buffer parser: code slices a read buffer into tokens, then appends to one token, clobbering the next — because every token sub-slice inherited capacity reaching to the buffer’s end. Second, the accidental-leak: a long-lived slice is created by slicing one element out of a megabyte buffer, and the whole megabyte stays pinned because capacity (hence the backing-array pointer) reaches the end of the original.

The defensive habits that eliminate both: re-slice with the three-index form when handing slices across an API boundary; slices.Clone (or copy) when extracting a small piece of a large buffer that will outlive it; and always s = append(s, ...). None of these are enforced by the compiler — they are discipline, reinforced by go vet only for the most blatant discarded-return case.

See Also