Go slices: the three-word header
- Last updated
- Prerequisites:
- Go basics: arrays, slices, functions
- Understanding of value semantics vs reference semantics
- go
- slices
- memory
- arrays
- aliasing
- append
Read at your depth
The practical view
A slice is a struct of three words: a pointer to the underlying array, len (length: how many elements are valid), and cap (capacity: how many fit before reallocation). s := make([]int, 3, 5) has len 3, cap 5. append(s, v) writes at s[len] if len < cap; when len == cap, it allocates a new array (usually doubling capacity), copies, and returns a NEW slice header — which is why you must assign the result: s = append(s, v). Slices are passed by value (the header is copied) but share the underlying array, so mutating elements inside a function is visible to the caller.
Legacy vs modern
Aliasing append into a sub-slice vs explicit capacity control
A shared backing array turns a local append into a caller-visible mutation; the full slice expression or a fresh copy restores isolation.
a := make([]int, 2, 4) // len 2, cap 4
b := a[:2]
b = append(b, 9) // writes INTO a's arrayb := append([]T(nil), a[:n]...) // fresh backing arrayInterview gotchas
Context
This is the classic Go aliasing question — the interviewer wants the len/cap condition that decides between in-place write and reallocation.
The mechanical answer
append writes the new element at s[len] and increments the header's len — no copy — whenever len < cap. Because the header shares the backing array, any other slice pointing into that array observes the write. When len == cap, append calls growslice: it allocates a new, larger array, copies all elements, and returns a new header pointing to the new array — the original header (and any others sharing the old array) are untouched. So whether 'the original' changes depends on whether the append stayed within the shared capacity. The full slice expression s[low:high:max] caps future appends by setting an explicit max capacity.
Trap
The naive answer is 'append copies the array'. It only copies on capacity exhaustion; most appends are in-place writes into shared memory. Another trap: 'slices are passed by reference' — the header is passed by value; only the backing array is shared. Candidates who demonstrate the len<cap branch versus the grow branch, and name the full slice expression as the isolation tool, nail this question.
Context
This is a production-memory question — the interviewer wants the GC reachability mechanism, not just the idiom.
The mechanical answer
The backing array is reachable (and therefore not collectable) as long as any slice header references it — the GC follows each header's Data pointer to the whole array, with no knowledge that only two elements are 'in use'. So keep := big[:3] pins an 8MB array for the life of keep. The fix is to copy the needed elements into a fresh, small allocation: keep := append([]T(nil), big[:3]...). The same mechanism explains why ranging and retaining one element (e.g., reading one record from a huge parse buffer) requires an explicit copy if the buffer must be freed.
Trap
The trap answer is 'GC frees unused parts of arrays' — the GC operates on whole objects, and the backing array is one object. Another trap: 'sub-slicing copies the data' — slicing only copies the 24-byte header; the data pointer keeps referencing the original array. Mentioning that the compiler/runtime has no partial-array liveness for slices (unlike the new slices.Collect idioms that copy deliberately) demonstrates precise knowledge.