A Go slice’s capacity isn’t just a suggestion; it’s a hard limit on how many elements you can add to the slice before Go has to make a whole new, bigger underlying array and copy everything over.

Let’s see this in action. We’ll start with a slice and watch its capacity as we add elements.

package main

import "fmt"

func main() {
	// Start with an empty slice
	s := make([]int, 0)
	fmt.Printf("Initial: len=%d, cap=%d, %v\n", len(s), cap(s), s)

	// Add one element
	s = append(s, 1)
	fmt.Printf("After 1: len=%d, cap=%d, %v\n", len(s), cap(s), s)

	// Add another
	s = append(s, 2)
	fmt.Printf("After 2: len=%d, cap=%d, %v\n", len(s), cap(s), s)

	// Add more until we likely exceed initial capacity
	for i := 3; i <= 10; i++ {
		s = append(s, i)
		fmt.Printf("After %d: len=%d, cap=%d, %v\n", i, len(s), cap(s), s)
	}

	// Add even more to see growth patterns
	for i := 11; i <= 30; i++ {
		s = append(s, i)
		fmt.Printf("After %d: len=%d, cap=%d, %v\n", i, len(s), cap(s), s)
	}
}

Running this code will show you how cap changes. You’ll see it start at 0, then jump to 1, then 2. After that, it’s not a simple increment. Go’s slice growth strategy is to double the capacity when it needs to reallocate, but with a twist for very small slices. Initially, it might grow from 0 to 1, then 1 to 2. Once it hits a capacity of 2, it will double to 4, then 8, 16, 32, and so on. This doubling strategy is an optimization to amortize the cost of reallocation over many appends.

The core concept here is that a slice is a descriptor for a contiguous section of an underlying array. This descriptor has three parts: a pointer to the first element of the slice within the array, the length of the slice (how many elements are currently in it), and the capacity of the slice (how many elements can fit in the underlying array from the slice’s starting point to the end of the array). When you append to a slice and its length exceeds its capacity, Go allocates a new, larger underlying array, copies the elements from the old array to the new one, and then appends the new element. The slice descriptor is then updated to point to this new array and reflect the new length and capacity.

The make([]T, length, capacity) function is your primary tool for controlling this. If you omit the capacity argument, it defaults to being equal to the length. So make([]int, 5) is equivalent to make([]int, 5, 5). If you know you’ll be adding many elements, pre-allocating with a larger capacity can save significant performance by reducing the number of reallocations. For example, make([]int, 0, 100) creates an empty slice but with enough room in its underlying array to hold 100 elements without reallocating.

It’s crucial to understand that slices do not copy their underlying arrays. When you assign one slice to another, both slices point to the same underlying array. This means modifying elements through one slice will affect the other. The append function, however, may return a new slice that points to a different underlying array if a reallocation occurs. This is why you must always reassign the result of append back to your slice variable: s = append(s, element). If you don’t, and append causes a reallocation, your original slice variable will still point to the old, smaller array, and you’ll have lost your data.

The exact growth factor is an implementation detail of the Go runtime, but it’s generally designed to be efficient. For small slices, the growth factor can be less than 2 to avoid frequent reallocations of tiny arrays. For larger slices, it typically doubles. This strategy aims to balance the cost of memory allocation and copying with the cost of wasted capacity.

The next thing you’ll likely run into is understanding how to efficiently create slices from existing data, especially when dealing with copy and potential aliasing issues with shared underlying arrays.

Want structured learning?

Take the full Golang course →