Go Slice Aliasing: The `append` That Silently Overwrites Your Caller's Data
Go · Intermediate · 6 min read · published
What this solves: Slicing a Go slice and appending to the result can overwrite elements the original slice still owns, producing corrupted data that only appears once capacity exceeds length. This shows you how to spot and prevent it.
The Problem
A pagination helper in a Go service:
func page(items []Result, offset, size int) []Result {
return items[offset : offset+size]
}
Callers get a page, then decorate it:
p := page(all, 0, 20)
p = append(p, sponsoredResult) // pin an ad to the end of the page
In unit tests with a 20-item fixture, perfect. In production with 10,000 results, users on page 1 see the ad — and users on page 2 see the ad as their first result, while the genuine item that belonged there has vanished. The bug rate is exactly 1 item per page, it never panics, and no log line mentions it. Support tickets say "a product disappeared from search."
Nothing was deleted. append overwrote it.
Why the Obvious Fix Falls Short
The reflex is: "slices are references, so I'll copy defensively." Reasonable — but developers usually reach for one of these:
copied := items[offset : offset+size] // still the same array
copied := append([]Result{}, items[offset:offset+size]...) // works, allocates always
The first does nothing at all: re-slicing never copies. The second is correct but heavy — you allocate and memcpy every page even in the 99% of calls that never append.
The deeper reason the reflex fails is that people believe append always returns a new array, or that "it only reuses capacity if there is room" — and then assume a slice of length 20 has no room. It has room: capacity is measured from the slice's start to the end of the backing array, not to the end of the slice. items[0:20] out of a 10,000-element array has len 20, cap 10000. append sees 9,980 spare slots and happily writes into index 20, which is a live element of the parent.
So the real fix isn't "copy more" — it's "stop lying to append about how much of the array you own."
How It Actually Works
A slice is a three-word header: pointer, length, capacity. Re-slicing produces a new header pointing into the same array, inheriting whatever capacity remains behind it.
append follows one rule: if len < cap, write in place and bump the length. Otherwise, allocate a bigger array, copy, and return a header pointing at the new one. That branch is why the bug is intermittent-looking: when cap == len, append allocates and everything is safe; when cap > len, it mutates shared memory.
flowchart TD
subgraph arr["backing array (10000 Results)"]
i0["[0..19] page 1"]
i20["[20] first item of page 2"]
i21["[21..] rest"]
end
P["page 1 header<br/>ptr=&arr[0] len=20 cap=10000"] --> i0
Q["page 2 header<br/>ptr=&arr[20] len=20 cap=9980"] --> i20
A["append(page1, ad)"] -->|"len(20) < cap(10000)<br/>⇒ write in place at index 20"| i20
A -.->|"if cap had been 20<br/>⇒ allocate new array"| N["new array, copy of 20 + ad"]
The three-index slice expression s[low:high:max] sets capacity explicitly: cap == max - low. Writing s[a:b:b] yields a slice whose capacity equals its length, so the very next append is guaranteed to allocate. This is a full slice expression, and it is free — no copy, just a different capacity word in the header.
Before and After
// BEFORE: capacity leaks the entire tail of the array.
// append() writes into items[offset+size], which belongs to the next page.
func page(items []Result, offset, size int) []Result {
if offset+size > len(items) {
size = len(items) - offset
}
return items[offset : offset+size] // len=size, cap=len(items)-offset
}
// AFTER: clamp cap to the slice's own length with the three-index form.
// Any append by the caller is forced to allocate, so items is never touched.
func page(items []Result, offset, size int) []Result {
end := min(offset+size, len(items))
return items[offset:end:end] // len == cap == end-offset
}
// If callers also mutate elements in place (not just append),
// capacity clamping isn't enough — hand them their own array:
func pageCopy(items []Result, offset, size int) []Result {
end := min(offset+size, len(items))
out := make([]Result, end-offset)
copy(out, items[offset:end])
return out
}
When NOT to Use This
- Hot loops that intentionally reuse capacity. The classic zero-allocation filter
out := in[:0]; for ... { out = append(out, v) }depends on inherited capacity. Clamping it there reintroduces allocations for no benefit — you own the array in that case. - When elements are mutated, not just appended.
s[a:b:b]protects the array beyond b; it does not stop the caller writingp[0].Name = "x". If element mutation is a concern, copy — and for pointer or slice-valued fields, remembercopyis shallow. - Buffer pools. If you're returning slices from a
sync.Poolto be refilled, capacity is the whole point; instead enforce ownership by contract and reset withbuf = buf[:0]. - Read-only APIs. Sometimes the cheapest fix is to document that the result aliases the input and let the caller decide, exactly as
bytes.Splitandstrings.Fields-style APIs do.
Gotchas
s[:0:0]is not the same ass[:0]. The former yields cap 0, so the first append allocates fresh; the latter reuses the array. Mixing them up either reintroduces aliasing or kills your allocation-free path.- The bug scales with your data. Tests where
cap == len(literal fixtures,make([]T, n)filled exactly) will always pass. Assert on capacity, not just contents:if cap(got) != len(got) { t.Fatal(...) }. bytes.Split,strings.Split,json.RawMessage, andReader.Bytes()all return aliasing sub-slices. Appending to a token frombytes.Splitcan corrupt the next token. Clamp or copy before you build on them.- Aliased slices are also data races. Two goroutines appending to two chunks of one array will trip
-race— rungo test -racein CI; it catches this class of bug that ordinary tests won't. - Removing an element with
append(s[:i], s[i+1:]...)shifts in place and leaves a stale copy of the last element ats[len(s)-1], still reachable by any other slice header over that array — and still holding a pointer alive for the GC. Zero the tail if the elements hold references. minis a builtin only from Go 1.21. On older versions write the comparison out, or you'll get a confusing "undefined: min" during the upgrade window.
Key takeaway: Any slice you derive with `s[a:b]` shares a backing array and inherited capacity — use the three-index form `s[a:b:b]` or an explicit `copy` before appending if the caller still owns that data.
Real-world challenge
A batch processor splits a 1,000-item `[]Event` into 100-item chunks with `events[i:i+100]` and hands each chunk to a goroutine. Each worker calls `chunk = append(chunk, retryEvent)` when it needs to re-enqueue a failure. Under load, your metrics show duplicate processing and occasionally an event that was never in the input at all. With small batches (under 100 items, one chunk) everything is fine. What is happening and how do you fix it?
Diagnosis
Each chunk events[i:i+100] has len == 100 but cap == 1000 - i — the tail of the original array. When a worker appends, it writes the retry event directly over events[i+100], which is chunk i+1's first element. Two goroutines are now racing over the same memory, which is also a data race (go test -race will flag it).
With a single chunk there is no next chunk to clobber, so the bug hides.
Add a quick probe:
log.Printf("chunk %d len=%d cap=%d", i, len(chunk), cap(chunk)) // cap != len ⇒ danger
Fix
Clamp the capacity at the chunk boundary with the three-index slice expression so append is forced to allocate:
end := min(i+100, len(events))
chunk := events[i:end:end] // cap == len; append copies instead of overwriting
If workers also mutate elements in place, capacity clamping is not enough — give each worker its own array:
chunk := make([]Event, end-i)
copy(chunk, events[i:end])