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:

GO
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
}
Output
[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:

GO
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)
		}
	}
}
Output
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:

GO
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)
}
Output
[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:

GO
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
}
Output
[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:

GO
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)
}
Output
[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:

GO
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))
}
Output
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:

GO
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)
}
Output
[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 append reallocates.
  • Always use the result of append; functions that append must return the slice.
  • Use s[low:high:max], slices.Clip or slices.Clone when 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.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
[0 7 0] 3 5
[7 0 9] 3 4
[0 7 0 9]
Next: the slices package.