Skip to content
gohacks.dev
Basics

The Append Trap That Silently Corrupts Another Slice

Two slices appended from one base can overwrite each other when spare capacity exists. See the bug, the path building version and four ways to fix it.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

This is the bug I would put first on a list of things that look right and are not. Nothing panics, go vet is quiet and every unit test with a short input passes. Then a search routine returns three paths that are all the same path. The cause is one sentence in the language specification, and it is the behavior of append when there is room to spare. If you want the underlying model first, slices from the header to the backing array builds it, and this article is the failure it predicts. It belongs with the rest of the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The smallest version of the bug

Take a base slice that has a length of three and room for ten. Append different values to it twice, keeping both results:

main.goGo
package main

import "fmt"

func main() {
	base := make([]int, 3, 10)

	a := append(base, 1)
	b := append(base, 2)

	fmt.Println("a:", a)
	fmt.Println("b:", b)
	fmt.Println("same array:", &a[3] == &b[3])
}
Text
a: [0 0 0 2]
b: [0 0 0 2]
same array: true

You would expect a to end in 1 and b to end in 2. Both end in 2, and the last line prints true. append saw len(base) < cap(base), so it did not allocate. It wrote 1 into slot three of the shared array and returned a header of length four. The second call wrote 2 into the same slot. The two results are two headers describing overlapping windows on one array, and the second write won.

Nothing was malfunctioning. This is the documented behavior, and it is why the Go team's own writing on slices tells you to treat the result of append as the new owner of the memory and to stop using the old value. The trap is that you did not stop using the old value: you kept base and branched from it.

The real-world version: building paths

Nobody writes the program above on purpose. The form it takes in real code is a loop that extends a prefix once per branch. Here is a depth-first walk that collects every route through a tiny graph and keeps each one:

main.goGo
package main

import "fmt"

var graph = map[string][]string{
	"start": {"a", "b", "c"},
	"a":     {"end"},
	"b":     {"end"},
	"c":     {"end"},
}

func walk(node string, path []string, out *[][]string) {
	if node == "end" {
		*out = append(*out, path)
		return
	}
	for _, next := range graph[node] {
		walk(next, append(path, next), out)
	}
}

func main() {
	start := make([]string, 1, 8)
	start[0] = "start"

	var routes [][]string
	walk("start", start, &routes)
	for _, r := range routes {
		fmt.Println(r)
	}
}
Text
[start c end]
[start c end]
[start c end]

There are three routes: through a, through b and through c. The output shows the same route three times, the last branch visited. Every recursive call received a slice with spare capacity, so append(path, next) wrote into the same slot each time, and the results stored in out all point at that one array. Remove the capacity from the setup, make([]string, 1) instead of make([]string, 1, 8), and the program would give correct output, because every append would have been forced to allocate. That is what makes the bug so hard to find: it depends on capacity, and capacity depends on how the slice was built, on the growth factor and even on the Go release. In a real program the slice was grown by an earlier loop, and the spare capacity was an accident.

The test written for this code uses a graph with one branch, and it passes. The test with two branches passes whenever the first append happened to hit a full slice. The failing case waits for production data.

Four fixes, and when I use each

Cap the capacity. The full slice expression s[low:high:max] limits the capacity of the result. Writing path[:len(path):len(path)] produces a slice that looks the same and has no spare room, so the next append must allocate:

main.goGo
package main

import "fmt"

func main() {
	base := make([]int, 3, 10)
	capped := base[:len(base):len(base)]

	a := append(capped, 1)
	b := append(capped, 2)

	fmt.Println("a:", a)
	fmt.Println("b:", b)
	fmt.Println("same array:", &a[3] == &b[3])
}
Text
a: [0 0 0 1]
b: [0 0 0 2]
same array: false

Now a ends in 1 and b in 2, and the arrays are different. This is the cheapest fix, one allocation per append, and it keeps the intent visible in one line. I use it at a boundary, where a function hands out a slice it still owns. Full slice expressions and capacity control covers the syntax and its panics.

Clone before you extend. slices.Clone(path) copies the elements into a new array with exactly the length of the original, so later appends are independent. It is the clearest option in recursive code: next := append(slices.Clone(path), step). It always allocates and copies the whole prefix, which is O(depth) per branch, and for routes in a graph that is the minimum anyway, since each result needs to own its data.

Copy at the point of storage. In the walk above, the actual damage happened when path was stored in out. Storing a copy, *out = append(*out, slices.Clone(path)), makes each saved result independent while the traversal keeps reusing one working slice, and it avoids a clone at every step. That is the best trade-off for a large search where most paths are never saved. Copying a slice the safe way compares the copying idioms.

Do not share the base. Build the result from scratch, make([]T, 0, n) and a loop. It is the most obvious fix and the one to prefer when a function can own its output.

How to find the bug in code you did not write

Look for these shapes in review:

  1. A slice parameter that the function appends to and then passes on, to a goroutine, a recursive call or a stored list.
  2. An append(x, ...) whose result is assigned to something other than x. The pattern y := append(x, v) leaves two slices alive that may share an array.
  3. Slices made with make([]T, n, m) where m > n, or buffers reused across loop iterations.
  4. A test that passes with one element and fails with two.

To prove it for a specific variable, print len and cap. If cap is greater than len at the append, and the original is still in use, you are in the zone. Comparing element addresses, as the first program did, answers the question outright. A unit test that mutates the returned slice and asserts the input did not change is the durable guard.

The race detector will not save you in a single goroutine, because the program is deterministic and no memory is touched from two threads. If the two slices are then used by different goroutines, the race detector does flag the concurrent writes, and that can be the first sign. Running tests with -race is still worth it, and Go concurrency patterns that survive production says more.

What each fix costs

FixAllocations per appendKeeps working slice reusable
Cap with [:len:len]1No, each append copies
slices.Clone before append1No
Clone only when storing1 per saved resultYes
Fresh make per result1 per resultYes

For small slices none of these matter. For a search over millions of nodes the difference between cloning at every step and cloning only when a result is kept can be the whole runtime. Measure with a benchmark before choosing, and prefer the version that is hard to misuse over the one that saves an allocation.

When the sharing is intended

There are programs where writing into spare capacity is the point. A buffer that is reset with buf = buf[:0] and refilled on each iteration is reusing one array on purpose, and it is how bytes.Buffer, strings.Builder and many parsers avoid allocations. The rule that keeps those safe is ownership: the code that resets the buffer knows nobody else holds a slice into it. The moment a result leaves the function, copy it. And if you are handing a slice to code you do not control, such as a callback or a goroutine, cap it or clone it first. A related symptom, an unexpected zero or a repeated value at the tail of a slice you thought you owned, is almost always this.

The specification's section on appending to and copying slices states the rule precisely, and the Go blog's slices usage and internals walks through the same example in more depth.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.