Skip to main content

Slices Demystified

If you're writing Go, you're using slices. While arrays are fundamental, they are static and rigid. Slices, on the other hand, provide the dynamism and flexibility that make Go development efficient.

However, slices have a reputation for being tricky. Concepts like length, capacity, and the invisible "underlying array" can confuse beginners. This guide breaks down the four core operations you need to master to use slices effectively: allocation with make(), creating views via slicing, growing with append(), and ensuring independence with copy().


The Anatomy of a Go Slice

A slice is not the data itself; it is a structure (a descriptor) that points to a segment of a contiguous block of memory (the underlying array). Every slice descriptor contains three crucial elements:

  • Pointer: Points to the first element of the segment in the underlying array.
  • Length (len): The number of elements currently accessible in the slice.
  • Capacity (cap): The number of elements available in the underlying array, starting from the slice's pointer, before new memory must be allocated.

1. Allocating Slices with make()

Unlike arrays, which are usually initialized with literal values, slices that need initial space are often created using the built-in make() function. This is how you allocate the necessary underlying array memory upfront.

The signature for make() for slices is: make([]T, length, capacity).

Example: Using make()

package main

import "fmt"

func main() {
// 1. Specify Length and Capacity
// We want a slice of integers with 5 elements initially (len=5),
// but the underlying array should be able to hold 10 elements (cap=10).
s := make([]int, 5, 10)

fmt.Printf("Length: %d, Capacity: %d\n", len(s), cap(s))
// Output: Length: 5, Capacity: 10
}

Insight: If you omit the capacity argument, Go assumes the capacity is equal to the length. If you specify make([]int, 0), you create an empty slice with zero length and zero capacity.


2. Key Slice Operation: Slicing (Creating Views)

Slicing is the act of creating a new slice descriptor that refers to a portion of an existing slice's (or array's) underlying memory.

The basic syntax is slice[low:high], where:

  • low is the starting index (inclusive).
  • high is the stopping index (exclusive).

The Basic Slicing Syntax

package main

import "fmt"

func main() {
// Underlying array holds 5 elements
source := []int{10, 20, 30, 40, 50}

// Create a new slice starting at index 1 (20) up to (but not including) index 4 (50).
subSlice := source[1:4]

fmt.Println("subSlice:", subSlice) // Output: [20 30 40]
fmt.Printf("Length: %d, Capacity: %d\n", len(subSlice), cap(subSlice))
// Length is 3 (20, 30, 40).
// Capacity is 4 (it can hold 20, 30, 40, and 50 before running out of space).
}

Controlling Capacity: The Full Slice Expression [low:high:max]

Sometimes you want to create a slice that prevents future additions from accessing the remaining elements of the underlying array. You can limit the new slice's capacity using a third index, max.

  • max is the index where the new slice's capacity ends (exclusive).
  • The capacity of the new slice will be $max - low$.
package main

import "fmt"

func main() {
source := []int{10, 20, 30, 40, 50} // len 5, cap 5

// Slicing [start:end:capacity_limit]
// Start at index 1 (20), end before index 3 (40).
// Limit capacity at index 3 (index 3 holds 40).
constrained := source[1:3:3]

fmt.Println("constrained:", constrained) // Output: [20 30]
fmt.Printf("Length: %d\n", len(constrained)) // Length: 2
fmt.Printf("Capacity: %d\n", cap(constrained)) // Capacity: 3 - 1 = 2
}

This syntax is vital when you want to pass a portion of a slice to a function but guarantee that the function cannot accidentally modify the elements beyond its intended scope via append().

The Shared Array Gotcha

Since slicing only creates a new view onto the same memory block, modifying elements in the new slice modifies the original source data.

package main

import "fmt"

func main() {
original := []int{1, 2, 3, 4}
view := original[1:3] // [2, 3]

// Modify an element in the 'view' slice
view[0] = 99

fmt.Println("View:", view) // Output: [99 3]
fmt.Println("Original:", original) // Output: [1 99 3 4]
// The original slice was mutated!
}

3. Growing Slices with append()

Slices are dynamic because of the built-in append() function. It handles adding elements and, crucially, managing the underlying memory when the capacity is exceeded.

Case 1: Capacity is Available

If the slice has remaining capacity, append() is fast. It simply places the new element into the next available spot in the existing underlying array and updates the slice's length.

Case 2: Capacity is Exceeded (Reallocation)

If the slice's capacity is full, append() performs a complex operation:

  1. It allocates a completely new, larger underlying array (usually doubling the current capacity).
  2. It copies all existing elements from the old array segment to the new array.
  3. It appends the new element(s) to the new array.
  4. It returns a new slice descriptor pointing to this new array.
package main

import "fmt"

func main() {
// Create a slice with len 2, cap 2
s := []int{1, 2}
fmt.Printf("Initial len: %d, cap: %d\n", len(s), cap(s))

// Append 3. Capacity is exceeded, reallocation occurs.
s = append(s, 3)
fmt.Printf("After append 3: len: %d, cap: %d\n", len(s), cap(s))
// Output: len: 3, cap: 4 (Capacity automatically increased)

// Append 4 and 5. Capacity is still available.
s = append(s, 4, 5)
fmt.Printf("After append 4, 5: len: %d, cap: %d\n", len(s), cap(s))
// Output: len: 5, cap: 8 (Capacity increased again, doubling the previous capacity)
}

Crucial Takeaway: When capacity is exceeded, the resulting slice is no longer tied to the original underlying array memory. This breaks the "shared array gotcha" for the newly appended slice.


4. Breaking the Bond with copy()

If you have a slice and need to create a true, independent duplicate—one that can be modified without affecting the original (or vice versa)—you must use the copy() function.

copy() requires that the destination slice has enough memory allocated to hold the elements from the source slice. This means you usually combine make() with copy().

package main

import "fmt"

func main() {
original := []int{10, 20, 30}

// 1. Allocate a destination slice with the correct length/capacity
independentCopy := make([]int, len(original))

// 2. Copy the elements from original to independentCopy
copy(independentCopy, original)

// Modify the copy
independentCopy[0] = 999

fmt.Println("Original:", original) // Output: [10 20 30] (Unchanged)
fmt.Println("Independent Copy:", independentCopy) // Output: [999 20 30]
}

Note on copy(): copy() copies the minimum number of elements possible between the source and destination slices. If the destination slice is smaller than the source, only the elements that fit are copied.


Summary of Slice Operations

OperationPurposeImpact on Underlying Array
make([]T, l, c)Allocate memory and define initial length/capacity.Creates the initial underlying array.
s[low:high]Create a new slice descriptor (view) referencing a segment.No change; creates shared memory access.
append(s, val)Add elements to the slice.If capacity is exceeded, allocates a new, larger array and moves data.
copy(dst, src)Create a true, independent duplicate.Forces an element-by-element copy into a new or existing array segment.

Mastering the interaction between length, capacity, and the underlying array is key to writing efficient and bug-free Go code. When in doubt about memory sharing, use make() combined with copy() to guarantee independence!