The Slice Header: A Pointer, Length, and Capacity Trio
In Go, understanding slices requires moving beyond the initial intuition that they are simply dynamic arrays. While they offer similar flexibility, their underlying mechanism is distinct. A Go slice is not the data itself, but rather a lightweight descriptor. This descriptor, known as a slice header, contains three crucial pieces of information: a pointer to the first element of an underlying array, the current length of the slice (the number of elements accessible), and the capacity of the slice (the maximum number of elements from the pointer onwards that can be used without reallocation).
This distinction is vital. When you create a slice, you are essentially creating a view into an array. Modifying elements through one slice can affect other slices that share the same underlying array. This shared memory model is powerful but also a common source of bugs if not fully understood. It means that slices offer efficient memory usage and fast operations because they often avoid copying large amounts of data. Instead, they operate on a contiguous block of memory managed by the array.
Consider a simple array: var arr = [5]int{1, 2, 3, 4, 5}. A slice derived from this array, like s := arr[1:3], would have a slice header pointing to the element with value 2 in the original array. Its length would be 2 (elements at index 1 and 2), and its capacity would be 4 (elements from index 1 to 4 of the original array: 2, 3, 4, 5).

Length vs. Capacity: The Core Distinction
The concepts of length and capacity are central to mastering Go slices. Length refers to the number of elements currently present in the slice. It dictates how many elements you can access directly by index. Capacity, on the other hand, represents the maximum number of elements the slice can hold, starting from its initial pointer, without needing to reallocate the underlying array. The capacity is always greater than or equal to the length.
When you append elements to a slice and the number of elements exceeds its current capacity, Go performs a reallocation. It allocates a new, larger underlying array, copies the existing elements into this new array, and then adds the new elements. The slice header is then updated to point to this new array, with its length and capacity adjusted accordingly. This reallocation is an expensive operation, so understanding capacity helps in optimizing performance by pre-allocating sufficient space when possible.
For example, if you have a slice s := make([]int, 3, 5), it means the slice has a length of 3 and a capacity of 5. You can access elements s[0], s[1], and s[2]. You can append up to two more elements (s[3] and s[4]) before a reallocation is necessary. If you append a fourth element, Go will create a new underlying array, copy the five elements into it, and the slice's capacity will increase, typically doubling or growing by a factor to amortize the cost of future appends.
The Dangers of Shared Underlying Arrays
The most significant pitfall when working with slices is the implicit sharing of their underlying arrays. Because multiple slices can point to the same array segment, modifications made through one slice can unexpectedly alter the data accessible by another. This is not a bug, but a consequence of how slices are designed for efficiency. Developers must be acutely aware of this behavior.
Consider two slices derived from the same array: arr := []int{1, 2, 3, 4, 5}, slice1 := arr[0:2], and slice2 := arr[2:4]. If you modify slice1[0] = 10, the original array now looks like [10, 2, 3, 4, 5]. Consequently, accessing slice2[0] will now yield 3, not the original 3. This can lead to subtle bugs that are hard to track down, especially in concurrent programs where multiple goroutines might be accessing and modifying slices derived from the same source.
To mitigate this, when you need to ensure that modifications to a slice do not affect other parts of your program, you must explicitly create a new slice with its own underlying array. This is often achieved by using the copy() function or by creating a new slice with make() and then appending the elements of the original slice to it. For instance, newSlice := append([]int{}, originalSlice...) will create a new slice with a new underlying array containing all elements from originalSlice.
Slicing and Appending: A Deeper Look
The append() function in Go is a cornerstone of slice manipulation. It takes a slice and one or more elements to add. If there is sufficient capacity in the underlying array, append() adds the elements and returns a new slice header reflecting the increased length. If the capacity is insufficient, append() allocates a new, larger array, copies the old elements, adds the new ones, and returns a slice header pointing to this new array.
The key takeaway from append() is that it may return a different slice header than the one it received. This is why it's crucial to always assign the result of append() back to the slice variable: mySlice = append(mySlice, element1, element2). Failing to do so means that if a reallocation occurred, your original slice variable will still point to the old, smaller underlying array, and the newly added elements will be lost to that variable.
Let's explore slicing further. You can create a new slice from an existing one using the syntax slice[low:high]. This creates a new slice that refers to the elements of the original slice's underlying array from index low up to (but not including) index high. The new slice's length will be high - low, and its capacity will be the original slice's capacity minus low. You can also use shorthand notation: slice[:high] is equivalent to slice[0:high], and slice[low:] is equivalent to slice[low:len(slice)]. Omitting both low and high, slice[:], creates a new slice that references the entire underlying array of the original slice, with the same length and capacity.
The `copy()` Function: Safe Data Transfer
When you need to copy elements from one slice to another, especially when you want to avoid the shared underlying array issue, the built-in copy() function is your tool. copy(dst, src) copies elements from the source slice src to the destination slice dst. It returns the number of elements copied, which is the minimum of len(dst) and len(src).
Crucially, copy() operates on the provided slices. If the destination slice has a smaller capacity or length than the source, only a subset of elements will be copied. If the destination is larger, the remaining elements in the destination slice will be unchanged. This function does not allocate new memory; it performs an in-place copy between the memory regions pointed to by the slice headers.
A common pattern for creating a completely independent copy of a slice is:
source := []int{1, 2, 3, 4, 5}
destination := make([]int, len(source))
copiedCount := copy(destination, source)
// Now, destination is an independent slice with its own underlying array
// copiedCount will be 5
This pattern ensures that any subsequent modifications to source will not affect destination, and vice versa. It is essential for scenarios where data integrity and isolation are paramount, such as passing data between different modules or goroutines that should not interfere with each other's state.
