Blog

Arrays and Slices in Go: len, cap and How append Really Works

A Go slice is a small window onto an array that holds the real values. Once you can see the window, len, cap, append and the bug where two slices change each other stop being mysterious.

Slices are the Go type you’ll use most. They’re also the one that surprises people most, because a slice looks like a list and mostly acts like one. But it isn’t one.

This post starts with arrays, because a slice is built on top of one. Every program below was run on Go 1.26, and its output is pasted from the run.

Arrays: fixed size, and copied whole

An array has a length that is part of its type. [3]int and [4]int are different types, and neither can grow.

package main

import "fmt"

func main() {
	a := [3]int{1, 2, 3}
	b := a // copies all three values
	b[0] = 99
	fmt.Println(a, b)
}

It prints:

[1 2 3] [99 2 3]

Assigning an array copies every element. b is a new set of three boxes, so changing it leaves a alone.

That makes arrays predictable. It also makes them rare in everyday code, because you almost never know the exact size up front. What you use instead is a slice.

A slice is a window onto an array

package main

import "fmt"

func main() {
	shelf := [6]string{"a", "b", "c", "d", "e", "f"}
	s := shelf[1:4]
	fmt.Println(s, len(s), cap(s))

	s[0] = "B"
	fmt.Println(shelf)
}

It prints:

[b c d] 3 5
[a B c d e f]

shelf[1:4] doesn’t copy anything. It makes a slice that looks at elements 1, 2 and 3 of the array that’s already there. Writing s[0] = "B" changed shelf, because there is only one set of boxes.

Explain it like I’m ten

Picture a long shelf of numbered boxes. That’s the array. It’s where the stuff really lives.

A slice is a cardboard window you hold up against the shelf. The window has three things written on it:

  • where it starts on the shelf
  • how many boxes it shows. That’s len.
  • how many boxes there are from its start to the end of the shelf. That’s cap.

If you reach through the window and swap a toy, the toy on the shelf changes. Anyone else holding a window over the same boxes sees the new toy too.

The precise version

A slice value is three words: a pointer to an element of an underlying array, a length and a capacity. Copying a slice, or passing it to a function, copies those three words. It never copies the elements.

len(s) is how many elements you can index. cap(s) is how many elements exist from the slice’s start to the end of the underlying array. In the example, s starts at element 1 of a 6-element array, so its capacity is 5.

Where the analogy breaks: a real window can’t make the shelf longer. append can do something like that, and the next section shows how.

append: same shelf if there’s room, a new shelf if there isn’t

append adds values to a slice, and whether it reuses the existing array or allocates a new one decides whether your program has a bug. Watch it first, then read the rules.

array A 10 20 30 40 old array: nothing points here now array B 1020 3040 50 s: len 3, cap 4, points at array A s: len 4, cap 4, points at array A s: len 5, cap 8, points at array B s := []int{10, 20, 30}, made with room for 4 s = append(s, 40): there is room, so the same array is used s = append(s, 50): no room left, so Go makes a bigger array the values are copied across, 50 is added, and s moves to B

append on a slice with length 3 and capacity 4. The first append fits in the spare box, so the same array is used. The second finds no room: Go allocates a bigger array, copies the values across, adds 50, and the slice now points at the new array. Nothing points at the old array any more, so the garbage collector will reclaim it.

Here are those steps in words, in case the animation doesn’t play for you:

  1. s holds three values in an array with room for four.
  2. append(s, 40) finds a spare box. It puts 40 there and returns a slice with length 4 over the same array.
  3. append(s, 50) finds no spare box. It allocates a new, larger array.
  4. It copies the four values across, adds 50, and returns a slice over the new array. The old array is left behind.

That’s why you always write s = append(s, x). append may hand back a slice over a different array, and if you don’t keep the result, you keep the old window.

How much bigger?

The Go specification doesn’t say. It’s a runtime detail, so it’s better to watch it than to remember it:

package main

import "fmt"

func main() {
	var s []int
	prev := -1
	for i := range 2000 {
		s = append(s, i)
		if cap(s) != prev {
			fmt.Printf("len %4d  cap %4d\n", len(s), cap(s))
			prev = cap(s)
		}
	}
}

It prints:

len    1  cap    4
len    5  cap    8
len    9  cap   16
len   17  cap   32
len   33  cap   64
len   65  cap  128
len  129  cap  256
len  257  cap  512
len  513  cap  848
len  849  cap 1280
len 1281  cap 1792
len 1793  cap 2560

Two things are worth noticing. The first append already reserves room for four, and small slices then double. Past a few hundred elements the growth slows down, so a huge slice doesn’t waste huge amounts of memory. The exact numbers can change between Go versions. What doesn’t change is the rule: a new array is allocated only when the old one is full.

If you know roughly how many elements are coming, say so up front and skip the regrowth:

package main

import "fmt"

func main() {
	ids := make([]int, 0, 1000)
	for i := range 1000 {
		ids = append(ids, i)
	}
	fmt.Println(len(ids), cap(ids))
}

It prints:

1000 1000

make([]int, 0, 1000) gives length 0 and capacity 1000. All thousand appends fit, and nothing is copied.

The bug: two slices that change each other

Put the shared array and the “same shelf if there’s room” rule together, and you get the most common slice bug in Go:

package main

import "fmt"

func main() {
	base := make([]int, 3, 4)
	a := append(base, 1)
	b := append(base, 2)
	fmt.Println(a, b)
}

It prints:

[0 0 0 2] [0 0 0 2]

You probably expected [0 0 0 1] [0 0 0 2]. Here is what happened. base has one spare box. The first append wrote 1 into it. The second append also had room, so it wrote 2 into the same box. a and b are two windows over one array, and the last write wins.

Nothing crashed, and nothing warned you. That’s why this bug survives code review.

The fix: limit the capacity

A full slice expression, s[low:high:max], sets the capacity too. With no spare room, append has to allocate:

package main

import "fmt"

func main() {
	base := make([]int, 3, 4)
	a := append(base[:3:3], 1)
	b := append(base[:3:3], 2)
	fmt.Println(a, b)
}

It prints:

[0 0 0 1] [0 0 0 2]

base[:3:3] means “the first three elements, and capacity 3”. Each append now finds the window full, so each gets a new array of its own.

When you just want an independent copy, slices.Clone says it more plainly. It comes up again below.

Passing slices to functions

Passing a slice to a Go function copies the three-word slice header, and that copy points at the same array:

package main

import "fmt"

func setFirst(s []int) {
	s[0] = 100
}

func addOne(s []int) {
	s = append(s, 1)
}

func main() {
	nums := []int{1, 2, 3}
	setFirst(nums)
	addOne(nums)
	fmt.Println(nums)
}

It prints:

[100 2 3]

setFirst changed the shared array, so the caller sees 100. addOne appended, but only to its own copy of the header. The caller’s nums still has length 3, so the extra element never shows up.

A function that needs to grow a slice returns the new one, the same way append does: nums = addOne(nums).

nil and empty slices

A nil slice and an empty slice both have length 0, but they are not the same value:

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var nilSlice []int
	empty := []int{}
	fmt.Println(nilSlice == nil, empty == nil)
	fmt.Println(len(nilSlice), len(empty))

	a, _ := json.Marshal(nilSlice)
	b, _ := json.Marshal(empty)
	fmt.Println(string(a), string(b))
}

It prints:

true false
0 0
null []

Both have length 0. You can range over both and append to both. A nil slice is a perfectly good empty slice, so var s []int is the normal way to start one.

The difference shows at the edges. A nil slice encodes to JSON as null, an empty one as []. That matters when you build a REST API later in this series. A client that expects an array may break on null.

Going past the end

Reading a slice past its length is a runtime panic in Go, not a silent read of whatever memory comes next:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	fmt.Println(s[:cap(s)])
	fmt.Println(s[3])
}

It prints the first line, then stops:

[1 2 3]
panic: runtime error: index out of range [3] with length 3

Indexing is checked against len, and slicing is checked against cap. Go never quietly reads memory past the end. It stops the program with a panic, which you’ll meet properly in the part on errors.

The slices package

Since Go 1.21 the standard library has a slices package for the jobs you used to write loops for:

package main

import (
	"fmt"
	"slices"
)

func main() {
	scores := []int{42, 7, 19, 7}
	clone := slices.Clone(scores)
	slices.Sort(clone)

	fmt.Println(scores, clone)
	fmt.Println(slices.Contains(scores, 19), slices.Index(scores, 7))
	fmt.Println(slices.Compact(clone))
}

It prints:

[42 7 19 7] [7 7 19 42]
true 1
[7 19 42]

slices.Clone makes a slice over a new array, so sorting the clone left scores untouched. slices.Compact removes consecutive duplicates, which is why it’s used after Sort.

What to remember

  • An array has a fixed size that’s part of its type, and assigning one copies every element.
  • A slice is a window onto an array: a pointer, len and cap. Copying a slice copies the window, not the values.
  • append reuses the array while there’s capacity, and allocates a bigger one when there isn’t. Always write s = append(s, x).
  • Two slices over one array can overwrite each other through append. Use s[low:high:max] or slices.Clone when they need to be independent.
  • A function can change a slice’s elements but not its length. Return the new slice instead.
  • A nil slice is a fine empty slice, but it encodes to JSON as null.

A slice doesn’t hold your values. It tells you where to find them.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.