Go Slices: Dynamic Arrays in Go
Go provides two ways to work with sequences of elements: arrays and slices. Arrays have a fixed size determined at compile time, while slices are dynamic and can grow or shrink during runtime. In...
Key Insights
- Slices are dynamic views over arrays with built-in length and capacity management, making them the default choice for collections in Go over fixed-size arrays
- Understanding the difference between length and capacity is critical—append operations that exceed capacity trigger reallocation and copying of the entire underlying array
- Slices share underlying arrays by default, which can cause subtle bugs when modifications affect multiple slices; use copy() when you need true independence
Introduction to Slices vs Arrays
Go provides two ways to work with sequences of elements: arrays and slices. Arrays have a fixed size determined at compile time, while slices are dynamic and can grow or shrink during runtime. In practice, you’ll use slices for nearly everything.
Arrays are value types—when you pass an array to a function, Go copies the entire array. This becomes inefficient for large datasets. Slices, on the other hand, are reference types that point to an underlying array, making them lightweight to pass around.
// Array: fixed size, part of the type
var arr [5]int = [5]int{1, 2, 3, 4, 5}
// Slice: dynamic size, more flexible
var slice []int = []int{1, 2, 3, 4, 5}
// The types are different
fmt.Printf("Array type: %T\n", arr) // [5]int
fmt.Printf("Slice type: %T\n", slice) // []int
The size is part of an array’s type, meaning [5]int and [10]int are completely different types. Slices have no such restriction, making them far more practical for real-world code.
Creating and Initializing Slices
Go offers multiple ways to create slices, each suited for different scenarios.
Slice literals are the most straightforward approach when you know the initial values:
// Slice literal
numbers := []int{10, 20, 30, 40, 50}
// Empty slice (not nil)
empty := []string{}
// Slice of structs
users := []User{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
The make() function gives you control over initial length and capacity:
// Create slice with length 5, capacity 5
s1 := make([]int, 5)
fmt.Println(s1) // [0 0 0 0 0]
// Length 3, capacity 10
s2 := make([]int, 3, 10)
fmt.Println(len(s2), cap(s2)) // 3 10
// Useful for pre-allocating when you know the size
result := make([]string, 0, 100) // length 0, capacity 100
Pre-allocating capacity with make() prevents multiple reallocations when you know approximately how many elements you’ll add.
You can also create slices from existing arrays or slices:
arr := [5]int{1, 2, 3, 4, 5}
// Slice the entire array
s1 := arr[:]
// Slice from index 1 to 3 (exclusive)
s2 := arr[1:4] // [2, 3, 4]
// From start to index 3
s3 := arr[:3] // [1, 2, 3]
// From index 2 to end
s4 := arr[2:] // [3, 4, 5]
Understanding Slice Internals: Length vs Capacity
A slice is a descriptor containing three fields: a pointer to an underlying array, the length, and the capacity. Understanding this structure is crucial for writing efficient Go code.
Length is the number of elements currently in the slice. Capacity is the number of elements in the underlying array, starting from the first element in the slice.
s := make([]int, 3, 5)
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
// len=3 cap=5 [0 0 0]
s = append(s, 1)
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
// len=4 cap=5 [0 0 0 1]
s = append(s, 2)
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
// len=5 cap=5 [0 0 0 1 2]
// This append exceeds capacity - triggers reallocation
s = append(s, 3)
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
// len=6 cap=10 [0 0 0 1 2 3]
When capacity is exceeded, Go allocates a new underlying array (typically doubling the capacity), copies all existing elements, and appends the new one. This is why pre-allocating capacity matters for performance-critical code.
Common Slice Operations
The append() function is the primary way to add elements to slices:
var s []int
s = append(s, 1) // append single element
s = append(s, 2, 3, 4) // append multiple elements
s = append(s, []int{5, 6, 7}...) // append another slice
fmt.Println(s) // [1 2 3 4 5 6 7]
Always assign the result of append() back to your slice variable. While append() modifies the slice in-place when capacity allows, it returns a new slice when reallocation occurs.
Copying slices requires the copy() function for true independence:
original := []int{1, 2, 3, 4, 5}
// Wrong: assignment creates a reference
wrong := original
wrong[0] = 99
fmt.Println(original) // [99 2 3 4 5] - original modified!
// Correct: copy creates independent slice
correct := make([]int, len(original))
copy(correct, original)
correct[0] = 99
fmt.Println(original) // [1 2 3 4 5] - original unchanged
Slice expressions support a three-index form [low:high:max] that sets the capacity:
s := []int{0, 1, 2, 3, 4, 5}
// Two-index form
s1 := s[1:4] // [1 2 3], cap = 5 (to end of original)
// Three-index form limits capacity
s2 := s[1:4:4] // [1 2 3], cap = 3 (4-1)
// This prevents s2 from accessing elements beyond index 4
fmt.Println(cap(s1), cap(s2)) // 5 3
Slice Gotchas and Best Practices
The most common pitfall is multiple slices sharing an underlying array:
original := []int{1, 2, 3, 4, 5}
slice1 := original[0:3]
slice2 := original[2:5]
slice1[2] = 99
fmt.Println(slice2) // [99 4 5] - slice2 affected!
Both slices point to the same underlying array. Modifying index 2 of slice1 modifies index 0 of slice2 because they reference the same memory location.
Memory leaks can occur when you keep a small slice from a large array:
// Bad: keeps entire 1GB array in memory
func processData(data []byte) []byte {
// Return just the first 10 bytes
return data[:10]
}
// Good: copy to release the large array
func processData(data []byte) []byte {
result := make([]byte, 10)
copy(result, data[:10])
return result
}
Nil vs empty slices behave differently:
var nilSlice []int
emptySlice := []int{}
fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false
// Both have length 0 and can be used with append
fmt.Println(len(nilSlice), len(emptySlice)) // 0 0
// JSON marshaling differs
json.Marshal(nilSlice) // "null"
json.Marshal(emptySlice) // "[]"
For most purposes, prefer var s []int (nil slice) unless you specifically need an empty JSON array.
Practical Use Cases
Slices excel at dynamic data manipulation. Here’s a practical filtering function:
func filter(numbers []int, predicate func(int) bool) []int {
// Pre-allocate with capacity
result := make([]int, 0, len(numbers))
for _, num := range numbers {
if predicate(num) {
result = append(result, num)
}
}
return result
}
// Usage
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := filter(numbers, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4 6 8 10]
Implementing a simple stack:
type Stack struct {
items []int
}
func (s *Stack) Push(item int) {
s.items = append(s.items, item)
}
func (s *Stack) Pop() (int, bool) {
if len(s.items) == 0 {
return 0, false
}
index := len(s.items) - 1
item := s.items[index]
s.items = s.items[:index]
return item, true
}
func (s *Stack) Peek() (int, bool) {
if len(s.items) == 0 {
return 0, false
}
return s.items[len(s.items)-1], true
}
Conclusion
Slices are Go’s workhorse data structure for collections. They combine the efficiency of arrays with the flexibility of dynamic sizing. Use arrays only when you need a fixed size known at compile time or when working with specific APIs that require them.
Remember these key points: pre-allocate capacity when you know the approximate size, use copy() when you need independent slices, and be aware of shared underlying arrays. Understanding the length/capacity distinction and how append() triggers reallocation will help you write more efficient Go code.
The simplicity of slices is deceptive—their power comes from understanding their internal structure and behavior. Master slices, and you’ll write cleaner, more idiomatic Go.