Slice questions are the most common “what does this print?” items in Go interviews. A typical one shows four lines with append and asks for the output, and the follow-up asks why a backtracking solution returns the same subset several times. Both have the same root cause: candidates think of a slice as a dynamic array that owns its elements, when it is really a small view onto memory that other slices may share.
Interviewers like the topic because it separates people who have read Go from people who have debugged it. Examples here assume Go 1.23 on a 64-bit platform.
Before you start
Know that Go arrays have a fixed length that is part of their type ([4]int and [5]int are different types) and that arrays are values: assigning one copies every element. Be comfortable with pointers and with the idea that passing a value to a function copies it. The slices package used below was added in Go 1.21.
The short answer
A slice is a three-word header: a pointer to an element of an underlying array, a length and a capacity. Slicing creates a new header over the same array, and passing a slice to a function copies only the header, so element writes are shared. append writes into the existing array when length is below capacity and otherwise allocates a larger array and copies, returning a header that points to the new one. That is why you always write s = append(s, v), and why two slices that share an array can overwrite each other.
How it works
You can picture the header as this struct, which matches the runtime’s internal layout:
type sliceHeader struct {
data unsafe.Pointer // first element visible through this slice
len int // elements you can index: s[0] to s[len-1]
cap int // elements from data to the end of the array
}Slicing s[low:high] produces a header with data moved forward by low, len = high - low and cap = cap(s) - low. Nothing is copied. A third index, s[low:high:max], also sets cap = max - low, which lets you hide the rest of the array.
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:3]
fmt.Println(s, len(s), cap(s)) // [20 30] 2 4
s[0] = 99
fmt.Println(arr) // [10 99 30 40 50]: same memoryappend(s, v) follows one rule. If len(s) < cap(s), it stores v at index len(s) of the existing array and returns a header with length plus one. Otherwise it calls the runtime’s growslice, which allocates a new array, copies the elements and appends. The growth policy doubles capacity for small slices, then eases toward about 1.25 times for slices above 256 elements, and finally rounds up to a memory allocator size class. Treat the exact numbers as an implementation detail; what matters is that you cannot tell from the code alone whether a given append reallocates.
A nil slice (var s []int) has a nil pointer and zero length and capacity. It works with len, range and append exactly like an empty slice; the differences are s == nil and JSON encoding (null versus []).
Step-by-step walkthrough
Step 1: Notice when two slices share an array
a := []int{1, 2, 3, 4}
b := a[:2] // len 2, cap 4: shares a's array
b = append(b, 99) // fits in capacity, so it writes a[2]
fmt.Println(a) // [1 2 99 4]Nothing in the code says “modify a”, which is why this bug survives code review. Whenever you take a sub-slice and later append to it, ask who else holds a header over that array.
Step 2: Cap the capacity with a full slice expression
a := []int{1, 2, 3, 4}
b := a[:2:2] // len 2, cap 2
b = append(b, 99) // no room: allocates a new array
fmt.Println(a, b) // [1 2 3 4] [1 2 99]The three-index form is the cheapest way to hand out a sub-slice that callers can append to without corrupting yours. The first append copies; after that the two slices are independent.
Step 3: Return the slice from functions that append
func addBroken(s []int, v int) { s = append(s, v) } // caller never sees v
func add(s []int, v int) []int { return append(s, v) }
nums := []int{1, 2, 3}
addBroken(nums, 4)
fmt.Println(nums) // [1 2 3]
nums = add(nums, 4)
fmt.Println(nums) // [1 2 3 4]The function receives a copy of the header. Writing s[0] = 9 inside it would be visible to the caller, because both headers point at the same array, but changing the local header’s length is not. This is the idiom behind append itself: it returns the new slice instead of mutating its argument.
Step 4: Copy deliberately when ownership changes
dst := make([]int, len(src))
copy(dst, src) // copy returns the number of elements copied
clone := slices.Clone(src) // Go 1.21: same effect in one callCopy when you store a slice beyond the current call, return part of an internal buffer, or keep a small piece of a large one. That last case matters for memory: header := data[:64] keeps the entire data array reachable, so a 64-byte header can pin a 10 MB file buffer in a cache. slices.Clone(data[:64]) lets the big array be collected.
Step 5: Preallocate when you know the size
out := make([]string, 0, len(users)) // len 0, cap n: one allocation
for _, u := range users {
out = append(out, u.Name)
}Preallocating avoids repeated growth and copying. Be careful not to write make([]string, len(users)) and then append, which leaves len(users) empty strings at the front.
Worked scenario
A candidate solves “return all subsets of [1, 2, 3]” with backtracking. The logic is right, but the output is wrong:
func subsets(nums []int) [][]int {
var res [][]int
var path []int
var dfs func(i int)
dfs = func(i int) {
if i == len(nums) {
res = append(res, path) // stores a header, not the elements
return
}
dfs(i + 1) // skip nums[i]
path = append(path, nums[i]) // take nums[i]
dfs(i + 1)
path = path[:len(path)-1] // undo
}
dfs(0)
return res
}
fmt.Println(subsets([]int{1, 2, 3}))
// [[] [2] [2] [1 2] [1] [1 2] [1 2] [1 2 3]]
// expected [[] [3] [2] [2 3] [1] [1 3] [1 2] [1 2 3]]Every entry in res is a header pointing into whichever array path used at that moment. When path is truncated and appended to again, it overwrites elements that earlier headers still point at. [3] became [2] because the next append reused the same one-element array. The exact garbage depends on when append happened to reallocate, which is why it looks random.
The fix copies the elements at the moment you record them:
if i == len(nums) {
res = append(res, slices.Clone(path))
return
}Now each result owns its own array and later mutations of path cannot reach it. The same bug appears in permutations, combination sums and any path-collecting DFS, so interviewers who ask DSA in Go look for this copy.
Common mistake
- “Slices are passed by reference.” The header is passed by value; only the elements are shared. That is why appends inside a function vanish.
- “append always copies” or “append never copies”. It depends on capacity at that moment.
- Ignoring the result of
append. It does not compile without assignment, butappend(other, x)assigned to the wrong variable still causes bugs. - Modifying the loop variable in
for _, v := range s { v.Count++ }.vis a copy; uses[i].Count++. - Holding a tiny sub-slice of a huge buffer in a long-lived structure, which keeps the whole buffer alive.
Verify the behavior
A table-driven test pins down both the aliasing and the fix:
func TestSubsetsAreIndependent(t *testing.T) {
got := subsets([]int{1, 2, 3})
want := [][]int{{}, {3}, {2}, {2, 3}, {1}, {1, 3}, {1, 2}, {1, 2, 3}}
if len(got) != len(want) {
t.Fatalf("got %d subsets, want %d", len(got), len(want))
}
for i := range want {
if !slices.Equal(got[i], want[i]) {
t.Errorf("subset %d = %v, want %v", i, got[i], want[i])
}
}
}Note that slices.Equal treats a nil slice and an empty slice as equal, which is what you want here: the first result is nil (a clone of the still-nil path) while want[0] is an empty literal. Run it with go test -run TestSubsetsAreIndependent -v against both versions: the broken one reports the wrong entries, the fixed one passes.
Follow-up questions
What is the difference between an array and a slice? An array is a fixed-size value type; a slice is a header over an array with a length that can grow through append.
How do you delete an element from a slice? s = slices.Delete(s, i, i+1). Since Go 1.22 it also zeroes the vacated tail elements, so they no longer keep pointers alive.
Why can arrays be map keys but slices cannot? Arrays of comparable elements are comparable with ==; slices are not comparable except to nil.
How much does a slice grow on append? Roughly double for small slices, easing toward 1.25 times for large ones, rounded to allocator size classes. Do not depend on exact capacities.
Interview exercise
What does this print, and why?
s := make([]int, 3, 10)
a := append(s, 1)
b := append(s, 2)
fmt.Println(a[3], b[3], len(s))Answer and reasoning
It prints 2 2 3. s has length 3 and capacity 10, so both appends fit in the existing array and write index 3 of the same memory. The first append stores 1 there and returns a header of length 4; the second stores 2 in the same slot, overwriting it. a and b are different headers over one array, so both read 2 at index 3, and s itself still has length 3. If s had been created with make([]int, 3), each append would reallocate and the output would be 1 2 3. The answer shows you reason from capacity rather than from the variable names.
Continue learning
Practise with the Go interview questions and the Go MCQs. Related notes: Go buffered vs unbuffered channels, ArrayList growth in Java for comparison, and stack versus heap memory. Primary sources: the Go blog’s slices: usage and internals, the slice types section of the specification and the slices package documentation.