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 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:
4 into array[3], returns slice [1,2,3,4]5 into array[3], returns slice [1,2,3,5] — but the first result now also reads 56 into array[3] — all three results now read 6All 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.
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.
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.
