Skip to content
intermediate Phase 5 · Arrays, Slices, Maps, and Iteration

append, copy, and Slice Aliasing

Handle capacity growth, copying, and the aliasing traps of shared backing arrays.

45m
0 problems
Topic Progress 0%

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:

  1. 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).
  2. Clone at the boundary — when a function receives a sub-slice and must not disturb the original, copy elements into fresh storage before mutating.
  3. Never mutate a slice you do not own — if the batch chunk is read-only input, tag a parallel []string of 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:

  1. Who allocated this backing array — me, my caller, or shared state?
  2. Does my view's capacity reach into storage others can read?
  3. 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?

Question 1 options

2. You need a sub-slice that may append freely without ever writing into the parent's storage. What do you write?

Question 2 options

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?

Question 3 options

4. You must filter evens out of a 10-million-element []int with minimal allocation. Which approach?

Question 4 options

Flashcards

Question

What two fates can append(s, x) have, and what determines which one happens?

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.

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?

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?

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?

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.

Related Reading