The Sub-Slice That Silently Overwrites
A batch worker processes events in chunks of two. The first version looks unremarkable:
events := []string{"a", "b", "c", "d", "e"}
for i := 0; i < len(events); i += 2 {
end := i + 2
if end > len(events) {
end = len(events)
}
chunk := events[i:end]
chunk = append(chunk, "tagged") // mark the batch
fmt.Println(chunk)
}
Output begins [a b tagged] — and on the next iteration, events no longer reads [a b c d e]. chunk is a header over events' own storage. Its length is 2 with capacity reaching to the end of the array, so append does not allocate: it writes the new element into events[2], clobbering "c". Aliasing means the write went through two names to one memory location.
Whether an append collides depends purely on available capacity:
a := []int{1, 2, 3, 4, 5}
b := a[:2] // len 2, cap 5
b = append(b, 99) // fits in cap: writes a[2]
fmt.Println(a) // [1 2 99 4 5] — parent corrupted
c := a[2:4:4] // len 2, cap 2 — sealed
c = append(c, 77) // must allocate; a untouched
fmt.Println(a) // [1 2 99 4 5]
Three defenses, in order of preference:
- Seal with the full slice expression —
s[i:j:j]makes the child's capacity equal its length, so any append allocates instead of writing back into the parent (Slice Basics and the Slice Header). - Clone at the boundary — when a function receives a sub-slice and must not disturb the original, copy elements into fresh storage before mutating.
- Never mutate a slice you do not own — if the batch chunk is read-only input, tag a parallel
[]stringof labels instead of writing into the input.
The bug class extends to structs: a struct field holding a slice shares its backing array with every copy of that struct. Passing Events{buf: events} around and appending to e.buf in one copy can affect another. Aliasing is not a slice defect — shared storage is the design — but every write to a slice element needs an answer to "who else can see this array?"
When append Decides to Leave
An append has two possible fates, and the header tells you which:
s := make([]int, 0, 4)
s = append(s, 1) // len 1 cap 4 — write in place
s = append(s, 2) // len 2 cap 4 — write in place
s = append(s, 3, 4, 5) // len 5 > cap: allocate, copy 2 existing + 3 new
While len < cap, append writes into existing storage and the header's pointer never changes. The moment the sum exceeds capacity, Go allocates a new, larger array, copies everything, and returns a header pointing at the new home. The old array stays alive only for other slices still referencing it.
Two details of the growth policy are worth carrying around: for small slices the new capacity roughly doubles, so repeated single appends stay linear overall instead of quadratic; for large slices growth becomes more gradual (on the order of 25% plus slack), so a tight for loop appending one element to a multi-megabyte slice can still surprise you. When the final size is known, reserve it:
out := make([]Row, 0, len(input)) // one allocation for the whole result
for _, in := range input {
out = append(out, Row{ID: in.ID})
}
The assignment s = append(s, x) is not ceremony. Forget it and two failures hide in plain sight: appends that fit in capacity appear to work while the variable still points at the old header, and appends that reallocate lose every element you thought you added. Any function returning a grown slice must pass the new header back — by return value, since parameters are local copies (Pointers as Function Parameters).
Two growth patterns deserve memory:
// Reserve n zero slots at the end in one call.
s = append(s, make([]int, n)...)
// Grow a byte buffer without counting capacity yourself.
buf = append(buf, payload...)
The make([]int, n)... form spreads n zeros into the append, which both extends length and — because appending n elements to a slice with fewer than n free slots allocates exactly for the final size — gives you a stable window to fill by index afterward.
copy, Clone, and Compacting in Place
append builds slices from scratch; copy moves elements between storage that already exists. Its signature rewards a careful read: copy(dst, src) returns the number of elements copied, stops at the shorter of the two lengths, and never allocates.
buf := make([]byte, 5)
n := copy(buf, "hello world") // copies 5 bytes — dst is the limit
fmt.Println(n, string(buf)) // 5 [hello]
That "shorter side wins" rule makes copy safe for overlapping regions, which is how a sliding window compacts itself:
// Keep only bytes before the first delimiter; shift the remainder down.
func trimBefore(sep byte, buf []byte) []byte {
if i := bytes.IndexByte(buf, sep); i >= 0 {
copy(buf, buf[i+1:]) // overlapping-safe shift
return buf[:len(buf)-i-1]
}
return buf
}
When the goal is a duplicate rather than a move, the idiomatic clone spreads one slice into a fresh one:
clone := append([]int(nil), src...) // new storage, same contents
Building from nil guarantees there is no shared backing array; slices.Clone(src) (Go 1.21+) is the same idea as a stdlib call. A plain clone := src is not a clone — it copies the header, and the aliasing bugs from the first chapter come right back.
Many transforms need no second slice at all. The two-index compaction pattern overwrites a prefix of the same array:
func compact(nums []int) []int {
n := 0 // write cursor: everything below n is kept
for _, v := range nums {
if v%2 == 0 {
nums[n] = v
n++
}
}
return nums[:n]
}
Read n as the boundary between "kept" and "garbage". Filtering a million elements allocates nothing extra, and the caller receives a shorter view of the same storage — with the understanding that elements past the new length are stale. copy for moving, append([]T(nil), s...) for duplicating, compaction for in-place filtering: together they cover nearly every slice reshaping you will write.
Aliasing's Usual Haunts: Struct Fields and Test Fixtures
The nastiest aliasing bugs are not written in loops — they arrive through shared ownership. Two places produce them repeatedly.
A slice field appended to under a value receiver. Methods on a value receiver get a copy of the struct, including its slice header. This compiles and quietly does nothing useful:
type Queue struct {
items []string
}
func (q Queue) Push(v string) { // value receiver: copies the header
q.items = append(q.items, v) // grows a local copy; caller's header unchanged
}
When capacity suffices, the element write lands in shared storage — the caller might see an element it cannot reach through its own length. When capacity is exceeded, append allocates and the caller sees nothing at all. Behavior that changes with capacity is the signature of this bug. Use a pointer receiver and treat the append's returned header as authoritative (Value Semantics and Copies).
Shared test fixtures. A package-level slice passed to every test becomes an accumulator:
var fixture = []string{"alpha", "beta"} // built once, shared by all tests
func TestProcess(t *testing.T) {
input := fixture // header copy, same storage
input = append(input, "x") // if cap allowed it, later tests see "x"
...
}
The defensive habit at boundaries: clone what you hand out, or clone what you receive when the callee might write. slices.Clone in tests costs microseconds and removes an entire category of order-dependent failures.
A quick ownership checklist for any slice write:
- Who allocated this backing array — me, my caller, or shared state?
- Does my view's capacity reach into storage others can read?
- Am I reassigning the header (
s = append(...)) or only writing elements?
If the answer to 2 is "others" and 3 is "elements", seal with s[i:j:j] or clone first. Carrying that checklist into Iterating Strings, Bytes, and Runes — where slicing decisions also have correctness weight, just a different kind — is the best bridge to the rest of the phase.
Quiz
1. a := []int{1,2,3,4,5}; b := a[:2]; b = append(b, 99). What is a after the append?
2. You need a sub-slice that may append freely without ever writing into the parent's storage. What do you write?
3. Inside a loop you do out = append(out, v) but forget to assign on one iteration (writing append(out, v) alone). When is the bug silent?
4. You must filter evens out of a 10-million-element []int with minimal allocation. Which approach?
Flashcards
Question
What two fates can append(s, x) have, and what determines which one happens?
Click to reveal answer
Answer
If len+x <= cap, it writes into the existing backing array and only the header's length changes. If it exceeds cap, Go allocates a larger array (roughly doubling for small slices), copies the elements over, and returns a header pointing at the new storage — which is why the result must always be reassigned.
Question
A caller reports that appending to a chunk they received from your function corrupted the original dataset. Name the mechanism and two fixes.
Click to reveal answer
Answer
The chunk was a two-index sub-slice whose capacity still reached into the parent array, so the append wrote in place. Fixes: seal the child with the three-index form s[i:j:j], or clone into fresh storage (append([]T(nil), s...) / slices.Clone) before handing it out.
Question
Why is clone := src not a clone?
Click to reveal answer
Answer
It copies the 24-byte header, so both variables share the backing array; writing clone[0] changes src[0]. A real clone copies elements into new storage — append([]int(nil), src...) or slices.Clone(src) — so the two can diverge freely.
Question
How do you append n zero elements in one operation, and when is that useful?
Click to reveal answer
Answer
s = append(s, make([]int, n)...): the make builds n zeros and the ... spreads them into append. It both extends length and typically allocates capacity for the final size in one shot, giving you a stable window to fill by index afterward.
Question
What does copy(dst, src) return, and how does it behave on overlapping slices?
Click to reveal answer
Answer
It returns the number of elements actually copied and stops at the shorter of len(dst) and len(src). Overlapping regions are handled correctly — copy(buf, buf[k:]) shifts data down safely — which makes it the tool for sliding-window compaction; it never allocates.
Revision Notes
Key Takeaways
- 1. append writes in place while capacity lasts and allocates the moment len meets cap — the returned header must always be reassigned
- 2. Aliasing bugs come from capacity, not from len: a two-index sub-slice can append straight into the parent's array
- 3. The three-index slice s[i:j:j] is the cheapest structural fix; cloning is the fix at ownership boundaries
- 4. copy(dst, src) returns the count, stops at the shorter side, and handles overlap — the sliding-window workhorse
- 5. Compaction (write cursor + return nums[:n]) filters large slices with zero allocation; preallocated append beats repeated growth
Interview Tips
- • "What does append return when capacity runs out?" — a new header over a new array, and say aloud that discarding it loses data
- • When shown a sub-slice mutation bug, name both fixes in one breath: seal with a three-index slice, or clone at the boundary
- • "How would you dedupe a slice in place?" — sketch the two-index compaction and note it allocates nothing
- • If asked about performance: show make([]T, 0, n) before a fill loop and explain that doubling keeps repeated appends amortized O(1)
Cheat Sheet
append, copy, and Slice Aliasing
append decision:
| Condition | What happens |
|---|---|
| len+n <= cap | writes in place; pointer unchanged |
| len+n > cap | new array, copy, new header returned — must reassign |
| small slice growth | roughly doubles (keeps appends amortized) |
| known final size | make([]T, 0, n) up front |
Aliasing defenses:
| Defense | Code |
|---|---|
| seal child capacity | child := parent[i:j:j] |
| clone elements | c := append([]T(nil), s...) or slices.Clone(s) |
| grow result | s = append(s, v) always reassign |
| reserve tail in one call | s = append(s, make([]T, n)...) |
Code template:
// In-place partition: evens to the front, no allocation.
func keepEven(nums []int) []int {
n := 0
for _, v := range nums {
if v%2 == 0 {
nums[n] = v
n++
}
}
return nums[:n]
}
// Safe child view that may append without touching the parent.
func child(s []string, i, j int) []string {
return s[i:j:j]
}
Ownership rules: header reassignment stays local; element writes are visible to every sharer; appending under capacity writes into shared storage — know who owns the array before you write.
Continue Learning
Before You Start
- No prerequisites listed.