How Slices Work
Most surprises with slices — a change showing up in another slice, or an append that seems to vanish — make sense once you know what a slice really is: a small header pointing into an array, with a length and a capacity. This lesson looks under the hood at length, capacity, how append grows a slice, and the patterns that avoid the classic bugs.
A slice is a view of an array
A slice value holds three things: a pointer to an element of an underlying array, a length (how many elements the slice shows) and a capacity (how many elements there are from the start of the slice to the end of the array). len and cap report the last two:
package main import "fmt" func main() { arr := [8]int{0, 10, 20, 30, 40, 50, 60, 70} s := arr[2:5] fmt.Println(s, len(s), cap(s)) // shows 3 elements; 6 available to the end of arr s = s[:cap(s)] // a slice can be re-extended up to its capacity fmt.Println(s, len(s), cap(s)) s[0] = -1 fmt.Println(arr) // the array changed: s points into it }
[20 30 40] 3 6 [20 30 40 50 60 70] 6 6 [0 10 -1 30 40 50 60 70]
Copying a slice copies only this header, which is why slices are cheap to pass to functions and why two slices can share the same elements.
How append grows a slice
If there's spare capacity, append writes into the existing array and returns a header with a bigger length. If there isn't, it allocates a new, larger array, copies the elements across and returns a header pointing there. Capacity grows in jumps — roughly doubling for small slices — so most appends don't need to allocate:
package main import "fmt" func main() { var s []int prevCap := -1 for i := range 20 { s = append(s, i) if cap(s) != prevCap { fmt.Printf("len %2d cap %2d\n", len(s), cap(s)) prevCap = cap(s) } } }
len 1 cap 4 len 5 cap 8 len 9 cap 16 len 17 cap 32
The exact growth sizes are an implementation detail and can change between Go versions; the important thing is that appending is efficient on average. When you know how many elements are coming, allocate the capacity up front with make([]T, 0, n) and avoid the copying altogether.
Why you must use append's result
Because append may return a header pointing to a new array, the old slice variable can be out of date. This is also why a function that appends must return the slice to its caller:
package main import "fmt" func addBroken(items []string, item string) { items = append(items, item) // changes only the local copy of the header } func add(items []string, item string) []string { return append(items, item) } func main() { list := []string{"a"} addBroken(list, "b") fmt.Println(list) list = add(list, "b") fmt.Println(list) }
[a] [a b]
The shared-array surprise
Two slices made from the same array share it until one of them outgrows the capacity. Appending to one can then overwrite elements the other can see:
package main import "fmt" func main() { base := make([]int, 3, 10) // room to grow without reallocating base[0], base[1], base[2] = 1, 2, 3 a := append(base, 100) // writes into base's array at index 3 b := append(base, 200) // writes into the SAME place fmt.Println(a, b) // a's last element was overwritten }
[1 2 3 200] [1 2 3 200]
The fix is to make sure a slice you're going to append to doesn't share spare capacity with another. A full slice expression s[low:high:max] sets the capacity to max - low, so the next append is forced to copy into a new array:
package main import "fmt" func main() { base := make([]int, 3, 10) base[0], base[1], base[2] = 1, 2, 3 limited := base[0:3:3] // len 3, cap 3: no spare room a := append(limited, 100) b := append(limited, 200) fmt.Println(a, b) }
[1 2 3 100] [1 2 3 200]
slices.Clip(s) does the same thing — removes unused capacity — and slices.Clone(s) makes a fully independent copy. When you return a sub-slice of internal data from a function, one of these protects the caller and you from each other.
Keeping a big array alive
A small slice of a large array keeps the whole array in memory, because the garbage collector can't free part of an array. If you read a 100 MB file and keep a 20-byte slice of it, the 100 MB stays. Copy what you need instead:
package main import ( "bytes" "fmt" "slices" ) func firstLine(data []byte) []byte { line, _, _ := bytes.Cut(data, []byte("\n")) return slices.Clone(line) // independent of data, so data can be freed } func main() { data := []byte("header line\n" + string(make([]byte, 1_000_000))) line := firstLine(data) fmt.Println(string(line), len(line), cap(line)) }
header line 11 16
(The clone's capacity can be a little more than its length; allocation sizes are rounded up.)
Preallocating
When a function builds a slice of known size, preallocate. make([]T, 0, n) plus append reads naturally; slices.Grow adds capacity to an existing slice:
package main import ( "fmt" "slices" "strings" ) func upperAll(words []string) []string { out := make([]string, 0, len(words)) for _, w := range words { out = append(out, strings.ToUpper(w)) } return out } func main() { fmt.Println(upperAll([]string{"go", "is", "fun"})) s := []int{1, 2} s = slices.Grow(s, 100) fmt.Println(len(s), cap(s) >= 102) }
[GO IS FUN] 2 true
Summary
- A slice is a header: pointer, length, capacity. Copying a slice copies the header, not the elements.
- Slices made from the same array share elements until an
appendreallocates. - Always use the result of
append; functions that append must return the slice. - Use
s[low:high:max],slices.Cliporslices.Clonewhen sharing would cause trouble. - Preallocate with
make([]T, 0, n)when you know the size.
Try it yourself
Predict the output before running it. Then change the capacity in make to 3 and predict again.
[0 7 0] 3 5 [7 0 9] 3 4 [0 7 0 9]
slices package.