This is THE classic Go interview topic. If we get one data structure question, it’s usually this.
Let’s start with the difference.
An array in Go has a fixed size baked into its type. [3]int and [4]int are literally different types. The size is part of the type.
A slice is a flexible, growable view over an array. It’s what we use 99% of the time.
var arr [3]int // fixed-size array, length is always 3
arr[0] = 10
s := []int{1, 2, 3} // slice, no size in the brackets
s = append(s, 4) // slices can grow
Why arrays are weird: they’re value types
In simple language, when we pass an array to a function or assign it, Go copies the whole thing. Every element.
a := [3]int{1, 2, 3}
b := a // b is a FULL copy
b[0] = 99
// a is still {1, 2, 3} — completely independent
That copying behavior surprises people. This is why we rarely use arrays directly. We use slices.
What a slice actually is
In simple language, a slice is a tiny struct with three fields:
- ptr — a pointer to where the data lives (the backing array)
- len — how many elements we can currently see
- cap — how many elements fit before we run out of room
make and slicing
make lets us pre-build a slice with a length and an optional capacity.
s := make([]int, 2, 5) // len 2, cap 5 — two zero-value ints, room for 5
fmt.Println(len(s), cap(s)) // 2 5
nums := []int{10, 20, 30, 40}
sub := nums[1:3] // a view: elements at index 1 and 2 → {20, 30}
fmt.Println(sub) // [20 30]
The key thing: nums[1:3] does NOT copy. sub points into the same backing array as nums. Think of it like a window onto the same data.
How append grows: capacity doubling
When we append and there’s spare capacity, Go writes into the existing backing array. Fast.
When we run out of capacity, Go allocates a new, bigger backing array (roughly doubling for small slices), copies everything over, and returns a slice pointing to the new array.
s := make([]int, 0, 2)
fmt.Println(len(s), cap(s)) // 0 2
s = append(s, 1, 2)
fmt.Println(len(s), cap(s)) // 2 2 — full
s = append(s, 3)
fmt.Println(len(s), cap(s)) // 3 4 — capacity grew (doubled)
This is why we always write s = append(s, x) — append may return a different slice.
The big gotcha: shared backing array
This trips up almost everyone. When two slices share a backing array, appending into the spare capacity of one can silently overwrite the other.
base := []int{1, 2, 3, 4, 5}
a := base[0:2] // {1, 2}, but cap is 5 (sees the rest)
a = append(a, 99) // writes into base's slot at index 2!
fmt.Println(base) // [1 2 99 4 5] — base got mutated!
We appended to a, but because a still had spare capacity inside base, the append clobbered base[2]. If we want a safe, independent copy, we use a full slice expression base[0:2:2] (caps the capacity) or just copy into a fresh slice.
Interview soundbite
An array has its size fixed in its type and copies by value; a slice is a lightweight header (pointer, length, capacity) that views a backing array. The classic gotcha is that slicing and appending can share that backing array, so an append into spare capacity can mutate another slice — append may also reallocate and double the capacity, which is why we always reassign the result.