Go's Slice Append Aliasing Trap

2026-04-30

You're building a utility that generates all possible one-element extensions of a path. Given a path like [1, 2, 3] and candidates [4, 5, 6], it should return [[1,2,3,4], [1,2,3,5], [1,2,3,6]]. A colleague writes this in Go:

package main

import "fmt"

func extend(path []int, candidates []int) [][]int {
    var results [][]int
    for _, c := range candidates {
        newPath := append(path, c)
        results = append(results, newPath)
    }
    return results
}

func main() {
    path := make([]int, 3, 8) // length 3, capacity 8
    path[0], path[1], path[2] = 1, 2, 3

    candidates := []int{4, 5, 6}
    results := extend(path, candidates)

    for _, r := range results {
        fmt.Println(r)
    }
}

You'd expect the output:

[1 2 3 4]
[1 2 3 5]
[1 2 3 6]

But instead you get:

[1 2 3 6]
[1 2 3 6]
[1 2 3 6]

Every result is identical — the last candidate stomped over all the others.

The Bug

The problem is how Go's append interacts with slice capacity. A Go slice is a three-word struct: a pointer to an underlying array, a length, and a capacity. When you call append and there's sufficient capacity in the backing array, Go does not allocate a new array. It writes into the existing one and returns a slice header pointing to the same memory.

Here, path has length 3 but capacity 8. That means there are 5 spare slots in the backing array. Every call to append(path, c) writes into the same slot — index 3 of the original backing array — because path itself never changes. Each iteration overwrites the previous value:

All three returned slices share the same backing array. They're not independent copies; they're different windows into the same memory.

This bug is especially insidious because it doesn't always manifest. If path were at capacity (e.g., make([]int, 3, 3) or built with a literal []int{1,2,3}), each append would trigger a new allocation and the code would work correctly. The bug only appears when there's excess capacity — which happens often with slices that were themselves built up via append, or allocated with a generous capacity hint.

This makes it a time bomb: it passes tests with small inputs, then silently corrupts data in production when slice capacities happen to be larger.

The Fix

Force a copy by clipping the slice's capacity before appending:

func extend(path []int, candidates []int) [][]int {
    var results [][]int
    for _, c := range candidates {
        newPath := append(path[:len(path):len(path)], c)
        results = append(results, newPath)
    }
    return results
}

The three-index slice expression path[:len(path):len(path)] creates a slice with the same length but with capacity equal to length — zero spare room. Now every append is forced to allocate a fresh backing array, making each result independent.

An alternative is to explicitly copy:

newPath := make([]int, len(path)+1)
copy(newPath, path)
newPath[len(path)] = c

Both approaches break the shared-memory aliasing. The three-index slice is more idiomatic Go for this pattern.

Key Takeaway: In Go, append only allocates when capacity is exhausted — if multiple slices share a backing array with spare capacity, appending to one silently corrupts the others; use a full slice expression s[:len(s):len(s)] to sever the alias.

All newsletters