Arrays and Slices in Go: How Slices Really Work
The slice header, shared backing arrays, append and capacity growth (Go 1.18+), and the classic interview puzzles explained step by step.
On this page
- Arrays: fixed size, value semantics
- What a slice really is
- Slicing an existing array
- Creating slices
- append and capacity growth
- Interview puzzle 1: two slices, one array
- Interview puzzle 2: modifying a slice inside a function
- Protecting a slice: the full slice expression
- Memory gotcha: small slices of big arrays
- Key takeaways
Slices are the most used data structure in Go and the source of the most interview questions. Once you understand that a slice is just a small view onto an array, every tricky append puzzle becomes predictable. This article builds that mental model step by step and verifies every output on Go 1.27.
Arrays: fixed size, value semantics
An array's length is part of its type. [3]int and [4]int are different types.
var arr [2]int // [0 0], zero valued
nums := [3]string{"I", "love", "Go"}
auto := [...]int{1, 2, 3} // compiler counts: [3]intArrays are values. Assigning or passing one copies every element:
a := [3]int{1, 2, 3}
b := a // full copy
b[0] = 100
fmt.Println(a, b) // [1 2 3] [100 2 3]Because of the fixed size and copying, you rarely use arrays directly. You use slices, which are built on top of them.
What a slice really is
A slice is a tiny struct with three fields, often called the slice header:
type slice struct {
ptr *T // pointer to the first element it can see in the backing array
len int // how many elements it currently holds
cap int // how many elements exist from ptr to the end of the backing array
}Think of a pizza (the backing array) and a slice of it: the slice remembers where it starts, how many pieces it has, and how many pieces remain until the end of the pizza.
Slicing an existing array
a[low:high] includes low and excludes high.
arr := [6]string{"This", "is", "a", "go", "interview", "question"}
s := arr[1:4]
fmt.Println(s, len(s), cap(s)) // [is a go] 3 5
s2 := s[1:2]
fmt.Println(s2, len(s2), cap(s2)) // [a] 1 4len(s) is 4 - 1 = 3.cap(s) counts from index 1 to the end of the array: 6 - 1 = 5, not 4.s2 starts one element later in the same array, so its capacity is 4.You can even "re-extend" a slice up to its capacity and see elements that were outside its length:
fmt.Println(s[:cap(s)]) // [is a go interview question]s, s2 and arr all share one backing array. Writing s2[0] = "A" changes arr[2] and s[1] too. Slicing never copies data.Creating slices
lit := []int{1, 2, 3, 4, 5} // slice literal: len 5, cap 5
m := make([]int, 3) // len 3, cap 3, values [0 0 0]
m2 := make([]int, 3, 5) // len 3, cap 5
var nilSlice []int // nil: ptr=nil, len 0, cap 0
empty := []int{} // empty but not nilA slice literal looks like an array literal without the size. Indexing beyond len panics even if capacity is larger:
m2 := make([]int, 3, 5)
m2[3] = 20 // panic: runtime error: index out of range [3] with length 3To use the extra capacity you append, or reslice: m2 = m2[:4].
nilSlice == nil is true and empty == nil is false, but both have length 0, and append, len and range work on both. Prefer checking len(s) == 0 over s == nil.
append and capacity growth
append writes into the existing backing array if there is spare capacity. Otherwise it allocates a bigger array, copies the elements, and returns a slice pointing to the new array. That is why you must always use the result: s = append(s, v).
var x []int
for i := 1; i <= 3; i++ {
x = append(x, i)
fmt.Printf("append %d -> len=%d cap=%d\n", i, len(x), cap(x))
}append 1 -> len=1 cap=1
append 2 -> len=2 cap=2
append 3 -> len=3 cap=4How much does it grow? Many tutorials (and my old notes) say "double until 1024, then grow by 25%". That was the rule up to Go 1.17. Since Go 1.18 the runtime doubles small slices up to a threshold of 256 elements, then grows by roughly newcap += (newcap + 3*256) / 4, which smoothly moves from 2x towards 1.25x. The result is then rounded up to a memory allocator size class.
Appending 2,000 ints one by one on Go 1.27 produces these capacities:
4 8 16 32 64 128 256 512 848 1280 1792 2560Doubling up to 512, then 848 (not 1024), then smaller steps. The exact numbers depend on the element size and Go version, so never write code that relies on them. If you know the final size, preallocate: make([]T, 0, n).
Interview puzzle 1: two slices, one array
var x []int
x = append(x, 1)
x = append(x, 2)
x = append(x, 3) // len 3, cap 4
y := x // copies the header, shares the array
x = append(x, 4) // fits in cap: writes array[3] = 4
y = append(y, 5) // y has len 3, cap 4: writes array[3] = 5 (overwrites!)
x[0] = 10
fmt.Println(x)
fmt.Println(y)[10 2 3 5]
[10 2 3 5]y := x copies only the header. Both point to the same array with spare room, so the second append overwrites the element the first one wrote. If x had been full (len == cap), append would have allocated a new array and they would have separated.
Interview puzzle 2: modifying a slice inside a function
func changeSlice(a []int) []int {
a[0] = 10
a = append(a, 11)
return a
}
aa := []int{1, 2, 3, 4, 5} // len 5, cap 5
aa = append(aa, 6) // full: new array, len 6, cap 10
aa = append(aa, 7) // len 7, cap 10
bb := aa[4:] // [5 6 7], len 3, cap 6
yy := changeSlice(bb)
fmt.Println(aa) // [1 2 3 4 10 6 7]
fmt.Println(yy) // [10 6 7 11]
fmt.Println(aa[0:8]) // [1 2 3 4 10 6 7 11]Step by step:
bb shares aa's array starting at index 4.a[0] = 10 writes to that shared array, so aa[4] becomes 10.append(a, 11) has spare capacity, so it writes 11 at index 7 of the same array. aa still has length 7, so it does not show the 11...aa[0:8] (allowed, because cap(aa) is 10) reveals it.append may or may not be visible. Return the new slice and let the caller assign it, exactly like the built-in append.Protecting a slice: the full slice expression
a[low:high:max] also limits capacity to max - low. With no spare capacity, the next append is forced to copy, so it cannot overwrite the original:
full := []int{1, 2, 3, 4, 5}
safe := full[1:3:3] // [2 3], len 2, cap 2
safe = append(safe, 99) // must allocate a new array
fmt.Println(full, safe) // [1 2 3 4 5] [2 3 99]To copy explicitly, use copy(dst, src) or slices.Clone(s) (Go 1.21+).
Memory gotcha: small slices of big arrays
A small slice keeps its entire backing array alive. If you read a 10 MB file and keep data[:10], all 10 MB stay in memory. Copy what you need instead: header := slices.Clone(data[:10]).
Key takeaways
{pointer, length, capacity} over a backing array.append writes in place when there is capacity, otherwise reallocates. Always use its return value.make([]T, 0, n) to preallocate, and s[a:b:b], copy or slices.Clone to avoid accidental sharing.Last updated on