What happens when you append to a slice, and when do two slices stop sharing memory?
append writes into the existing backing array when spare capacity exists and allocates a fresh array only when it does not, so whether two slices still alias each other after an append depends on capacity rather than on anything visible at the call site.
What the interviewer is scoring
- Does the candidate describe the slice as a three-word header over an array, rather than as a resizable list
- Whether they say out loud that append may or may not reallocate, and that the caller cannot tell which happened
- That they can produce the subslice-write-mutates-the-parent bug from memory rather than recognising it when shown
- Whether the three-index slice expression is reached for as a deliberate guard, not recited as trivia
- Does the candidate connect slice aliasing to retained memory, not just to wrong values
Answer
A slice is a header, not a container
A slice value is three words: a pointer to an element of some backing array, a length, and a capacity. The array is the data; the slice is a view onto it. Everything surprising about append follows from the fact that the header is copied by value on every assignment and every function call, while the array it points at is shared.
Capacity is the part people skip, and it is the part that decides behaviour. Length is how many elements you may index; capacity is how many array slots exist from the slice's start pointer to the end of the backing array. base[:2] on a slice with capacity 4 gives you a length of 2 and a capacity of still 4, because the slots beyond the new length have not gone anywhere.
Why the same append sometimes copies and sometimes does not
append checks whether len < cap. If so, it writes the new element into the array slot already sitting there, bumps the length, and returns a header pointing at the same array. If not, it allocates a larger array, copies the existing elements across, writes the new element, and returns a header pointing somewhere else entirely. Both paths look identical at the call site, which is the whole difficulty.
base := make([]int, 3, 4) // len 3, cap 4: one spare slot
base[0], base[1], base[2] = 1, 2, 3
head := base[:2] // len 2, cap 4 - same backing array as base
head = append(head, 99) // cap is free, so this WRITES base[2]
fmt.Println(base, head) // [1 2 99] [1 2 99]
head = append(head, 100) // fills the last slot; still no copy
fmt.Println(base, len(head), cap(head)) // [1 2 99] 4 4
head = append(head, 101) // cap exhausted: new array, the two are now independent
head[0] = -1
fmt.Println(base, head) // [1 2 99] [-1 2 99 100 101]
Three things worth reading off that. The first append silently overwrote base[2], destroying the value 3 through a slice that never mentioned base. The second append wrote array slot 3, which base cannot see at all because its own length is still 3 — so the two slices now disagree about the same memory. And the third append broke the relationship permanently, so from that line onwards writes through head are invisible to base. The aliasing is not a stable property you can reason about once; it changes underneath you.
How much larger the new array is deliberately unspecified. The runtime's growth strategy has changed across releases — Go 1.18 replaced the old "double until 1024 elements, then grow by a quarter" rule with a smoother ramp that begins transitioning around 256 elements — so treat the amortised cost as O(1) per element and never write code that depends on a particular capacity coming back.
Where the sharing turns into a bug
The realistic version of this is not a contrived make call, it is a function that hands out a subslice. A parser returns buf[:n] for the token and keeps buf[n:] as the remainder; a helper splits a batch into a head and a tail; a middleware slices a header value out of a request buffer. In every case the receiver holds a slice whose capacity extends into memory that somebody else still considers theirs, and the first append the receiver performs writes into it.
This is why the bug survives review. The write is not out of bounds, there is no panic, no data race in the -race sense if it happens on one goroutine, and the offending line is append, which every reader has been trained to see as safe. The symptom shows up somewhere else entirely, as a value in the parent slice that changed with nothing having assigned to it.
The third index is the guard
The full slice expression s[low:high:max] sets capacity explicitly, and the idiom is to make capacity equal length so that any append is forced to allocate rather than reuse:
// head is capped at n, so append(head, ...) must copy.
// Without the third index, that append would clobber tail[0].
func split(s []string, n int) (head, tail []string) {
return s[:n:n], s[n:]
}
Use it whenever you return a subslice of memory you did not want the caller to extend into, and whenever you hold a subslice of memory you do not own. It costs an allocation only in the case where an allocation was the correct behaviour anyway. Note that s[:n:n] guards appends but not element writes: the caller can still assign head[0], and the parent will see it. If you need genuine isolation, copy.
A small slice can pin a large array
The last consequence is about memory rather than correctness. The garbage collector reclaims whole allocations, not the unreferenced parts of them, so a slice keeps its entire backing array alive regardless of how little of it the slice covers. Return an 8-byte header out of a 10 MB read and you have retained 10 MB for as long as anything holds that 8-byte slice.
// Pins the whole buf allocation for the lifetime of the result.
func magicRetaining(buf []byte) []byte { return buf[:8] }
// Retains exactly 8 bytes. slices.Clone is stdlib since Go 1.21;
// bytes.Clone since Go 1.20; a make plus copy works on any version.
func magicCopying(buf []byte) []byte { return slices.Clone(buf[:8]) }
The same reasoning applies to shrinking a slice of pointers. s = s[:0] sets the length to zero but leaves every pointer sitting in the array, still reachable, still keeping its target alive — which is exactly what you want when you are reusing the buffer, and a leak when you are not.
Capacity, not length, is what determines whether two slices share memory, and capacity is invisible when you read
append(x, y)on the page. Whenever you hand out or receive a subslice, decide deliberately whether it should be extendable, and say so with the third index.
Likely follow-ups
- Why must you always assign the result of append back to a variable?
- You pass a slice to a function and it appends to it. Why does the caller sometimes see the change and sometimes not?
- How would you delete an element from the middle of a slice without leaking the pointer it held?
- What does copy return, and how does it behave when the two slices overlap?
Related questions
- A hot path allocates heavily and garbage collection is showing up in your profile. What does Span give you that a substring does not?hardAlso on memory and garbage-collection4 min
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on garbage-collection and memory5 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on memory and garbage-collection5 min
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on garbage-collection and memory6 min
- How would you rewrite a row-by-row pandas transformation, and what is SettingWithCopyWarning telling you?mediumAlso on memory5 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardAlso on garbage-collection6 min
- Why would you stream a large file rather than read it into memory, and what is backpressure?mediumAlso on memory4 min
- How do generators reduce memory use, and when are they the wrong choice?mediumAlso on memory4 min