Understanding Slices in Go: Dynamic Arrays in Action
James Reed
Infrastructure Engineer · Leapcell

Go's slice type is a powerful and flexible construct that sits at the heart of many Go programs. While often conceptually compared to dynamic arrays, it's crucial to understand that slices are not arrays themselves, but rather views or references into underlying arrays. This distinction is key to grasping their behavior, performance characteristics, and how to use them effectively.
What is a Slice? A View into an Array
At its core, a Go slice is a data structure consisting of three components:
- A Pointer (Pointer): This points to the first element of the underlying array that the slice refers to. It's not necessarily the beginning of the underlying array itself, but the starting point of the slice's view.
- Length (len): This is the number of elements currently accessible through the slice. It represents the length of the view.
- Capacity (cap): This is the number of elements in the underlying array, starting from the slice's pointer, that are available for the slice to use without reallocation. It represents the maximum extent of the potential view.
Visually, imagine an underlying array in memory. A slice merely defines a window over a contiguous portion of that array.
// An underlying array var underlyingArray [10]int // A slice 's' viewing a portion of underlyingArray // s points to index 2 of underlyingArray // s has a length of 3 (elements at index 2, 3, 4) // s has a capacity of 8 (elements from index 2 to 9) s := underlyingArray[2:5]
This design provides immense flexibility. Multiple slices can reference the same underlying array, potentially overlapping or viewing different segments. This behavior is crucial when understanding operations like copy
and how modifications to one slice might be visible through another.
Creating Slices
There are several ways to create slices in Go:
1. Slicing an Existing Array or Slice
This is the most common way to create a slice, leveraging an existing array or slice. The syntax a[low:high]
creates a slice that includes elements from low
up to (but not including) high
.
arr := [5]int{10, 20, 30, 40, 50} // Slice from index 1 (inclusive) to index 4 (exclusive) s1 := arr[1:4] // s1 == {20, 30, 40} fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1)) // s1: [20 30 40], len: 3, cap: 4 (elements from 20 to 50 are available) // Slices can omit low and high bounds: s2 := arr[2:] // s2 == {30, 40, 50}, len: 3, cap: 3 s3 := arr[:3] // s3 == {10, 20, 30}, len: 3, cap: 5 s4 := arr[:] // s4 == {10, 20, 30, 40, 50}, len: 5, cap: 5 // Slicing another slice: s5 := s1[1:] // s5 == {30, 40}, len: 2, cap: 3 (s1's underlying elements from 30 to 50 are available)
The capacity of a new slice created by slicing is determined by the capacity of the original slice/array minus the low
index. This ensures the new slice cannot access elements outside the original slice's or array's bounds.
2. Using make()
The make()
function is used to create slices with a specified length and an optional capacity. When make
creates a slice, it allocates a new underlying array in memory.
// Create a slice of integers with length 5 and capacity 5 // All elements are initialized to their zero value (0 for int) s := make([]int, 5) // s == {0, 0, 0, 0, 0}, len: 5, cap: 5 fmt.Printf("s: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // Create a slice of strings with length 3 and capacity 10 // The additional capacity can be used by append operations without reallocation s2 := make([]string, 3, 10) // s2 == {"", "", ""}, len: 3, cap: 10 fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))
3. Using a Composite Literal
You can initialize a slice directly using a composite literal, similar to arrays, but without specifying the size. Go infers the length and capacity based on the provided elements. A new underlying array is allocated.
scores := []int{100, 95, 80, 75} // scores == {100, 95, 80, 75}, len: 4, cap: 4 fmt.Printf("scores: %v, len: %d, cap: %d\n", scores, len(scores), cap(scores)) names := []string{"Alice", "Bob", "Charlie"} // names == {"Alice", "Bob", "Charlie"}
Essential Slice Operations
1. len(s)
: Current Length
The len()
built-in function returns the number of elements currently in the slice. This is the "visible" size of the slice.
mySlice := []int{1, 2, 3, 4, 5} fmt.Println(len(mySlice)) // Output: 5 subSlice := mySlice[1:3] // {2, 3} fmt.Println(len(subSlice)) // Output: 2
2. cap(s)
: Underlying Capacity
The cap()
built-in function returns the capacity of the slice, which is the number of elements in the underlying array beginning from the slice's pointer that are available for the slice. This is crucial for understanding when the underlying array will be reallocated.
mySlice := []int{1, 2, 3, 4, 5} fmt.Println(cap(mySlice)) // Output: 5 (initially, len == cap for literal) subSlice := mySlice[1:3] // {2, 3} fmt.Println(cap(subSlice)) // Output: 4 (elements from index 1 to 4 of mySlice's array) anotherSlice := make([]int, 2, 10) // len:2, cap:10 fmt.Println(len(anotherSlice), cap(anotherSlice)) // Output: 2 10
3. append(s, elems...)
: Adding Elements
The append()
built-in function is the primary way to add new elements to a slice. It conceptually returns a new slice containing the original elements plus the new ones. There are two scenarios:
- Sufficient Capacity: If the slice's capacity is large enough to accommodate the new elements,
append
will simply extend the slice's length and modify the existing underlying array. The returned slice will likely point to the same underlying array but with an updated length. - Insufficient Capacity: If there isn't enough capacity,
append
allocates a new, larger underlying array. It copies all the existing elements from the old array to the new one, appends the new elements, and then returns a slice that points to this new array. The old underlying array (and the slice that points to it) becomes eligible for garbage collection if no other references exist.
Go's growth strategy for underlying arrays is typically to double the capacity when reallocation is needed, up to a certain threshold, then a smaller factor (e.g., 1.25x) for very large slices. This amortizes the cost of reallocations.
var numbers []int // nil slice, len:0, cap:0 numbers = append(numbers, 10) // numbers: [10], len:1, cap:1 (new underlying array) fmt.Printf("After 10: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers)) numbers = append(numbers, 20) // numbers: [10 20], len:2, cap:2 (new underlying array, usually double) fmt.Printf("After 20: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers)) numbers = append(numbers, 30, 40) // numbers: [10 20 30 40], len:4, cap:4 (new underlying array again) fmt.Printf("After 30, 40: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers)) // Appending one slice to another requires '...' moreNumbers := []int{50, 60} numbers = append(numbers, moreNumbers...) // numbers: [10 20 30 40 50 60] fmt.Printf("After appending slice: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers))
Important Note on append
: Because append
might return a new slice (pointing to a different underlying array), it's crucial to assign its result back to the original slice variable. Failing to do so will result in the original slice remaining unchanged (or referencing the old, potentially full, underlying array).
s := []int{0, 1, 2} fmt.Printf("s before append: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // s before append: [0 1 2], len: 3, cap: 3 // This append reallocates and returns a NEW slice, but `s` still points to the old one. append(s, 3, 4) fmt.Printf("s after UNASSIGNED append: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // s after UNASSIGNED append: [0 1 2], len: 3, cap: 3 // Correct way: Assign the result back s = append(s, 3, 4) fmt.Printf("s after ASSIGNED append: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // s after ASSIGNED append: [0 1 2 3 4], len: 5, cap: 6 (or 8 depending on Go version/architecture)
4. copy(dst, src)
: Copying Elements
The copy()
built-in function copies elements from a source slice (src
) to a destination slice (dst
). It copies min(len(src), len(dst))
elements. copy
does not allocate a new underlying array; it operates on existing arrays.
source := []int{10, 20, 30, 40, 50} destination := make([]int, 3) // destination: {0, 0, 0} n := copy(destination, source) // Copies 3 elements (10, 20, 30) from source to destination fmt.Printf("Copied %d elements\n", n) // Output: Copied 3 elements fmt.Printf("source: %v\n", source) // source: [10 20 30 40 50] fmt.Printf("destination: %v\n", destination) // destination: [10 20 30] // Copying more than available in source, or fewer than destination can hold destination2 := make([]int, 10) copy(destination2, source) // Copies all 5 elements from source to destination2 fmt.Printf("destination2: %v\n", destination2) // destination2: [10 20 30 40 50 0 0 0 0 0] // Self-copying (can be used for in-place modifications like shifting) s := []int{1, 2, 3, 4, 5} copy(s[1:], s[0:]) // Shifts elements: s[1] gets s[0], s[2] gets s[1], etc. fmt.Printf("Shifted slice: %v\n", s) // Shifted slice: [1 1 2 3 4]
copy
is frequently used for:
- Creating a true independent copy of a slice's data.
- Implementing custom slice manipulations (e.g., insertion, deletion, filtering).
Slice Pitfalls and Best Practices
1. Modifying Underlying Arrays
Since slices are views, modifying an element through one slice will affect any other slices that share the same underlying array, provided the modification falls within their respective views.
arr := [5]int{1, 2, 3, 4, 5} s1 := arr[1:4] // s1 == {2, 3, 4} s2 := arr[2:5] // s2 == {3, 4, 5} fmt.Printf("Initial: s1=%v, s2=%v, arr=%v\n", s1, s2, arr) s1[1] = 99 // This modifies arr[2] fmt.Printf("After s1[1]=99: s1=%v, s2=%v, arr=%v\n", s1, s2, arr) // Output: // Initial: s1=[2 3 4], s2=[3 4 5], arr=[1 2 3 4 5] // After s1[1]=99: s1=[2 99 4], s2=[99 4 5], arr=[1 2 99 4 5]
This behavior is usually desirable for efficiency, but it requires careful thought to avoid unintended side effects. If you need a fully independent copy, use append
or copy
.
original := []int{1, 2, 3} // Create a truly independent copy independentCopy := make([]int, len(original), cap(original)) copy(independentCopy, original) independentCopy[0] = 99 fmt.Printf("Original: %v, Independent Copy: %v\n", original, independentCopy) // Output: Original: [1 2 3], Independent Copy: [99 2 3]
2. "Memory Leaks" with Sub-slices
A common concern is that if you take a small sub-slice from a very large underlying array and only keep the sub-slice, the original large array might not be garbage collected because the sub-slice still holds a reference to it. This can lead to keeping more memory than strictly necessary.
func createBigSlice() []byte { bigData := make([]byte, 1<<20) // 1MB slice // ... populate bigData ... return bigData[500:510] // return a small slice from the middle } // The underlying 1MB array will remain in memory as long as the 10-byte slice returned from createBigSlice() is reachable.
To prevent this, if you need only a small portion of a large slice and want the rest to be garbage collected, create a new underlying array for your smaller slice using copy
:
func createSmallIndependentSlice(bigData []byte) []byte { smallSlice := bigData[500:510] // Create a new slice with its own underlying array independentSmallSlice := make([]byte, len(smallSlice)) copy(independentSmallSlice, smallSlice) return independentSmallSlice } // Now, the 'bigData' slice can be garbage collected if no other references exist.
3. Nil Slices vs. Empty Slices
- Nil slice:
var s []int
ors := []int(nil)
. It haslen == 0
andcap == 0
. It's safe to calllen
,cap
,append
, andrange
on a nil slice. - Empty slice:
s := []int{}
ors := make([]int, 0)
. It also haslen == 0
andcap == 0
(for[]int{}
) or a specified capacity (formake
).
While functionally similar in many contexts, a nil slice truly represents "no underlying array," whereas an empty slice might point to a zero-length array. It's generally good practice to use nil
slices for the "zero value" of a slice, as append
correctly handles them.
var nilSlice []int emptySlice := []int{} madeEmptySlice := make([]int, 0) fmt.Printf("nilSlice: %v, len: %d, cap: %d, is nil: %t\n", nilSlice, len(nilSlice), cap(nilSlice), nilSlice == nil) fmt.Printf("emptySlice: %v, len: %d, cap: %d, is nil: %t\n", emptySlice, len(emptySlice), cap(emptySlice), emptySlice == nil) fmt.Printf("madeEmptySlice: %v, len: %d, cap: %d, is nil: %t\n", madeEmptySlice, len(madeEmptySlice), cap(madeEmptySlice), madeEmptySlice == nil) // All are safe to append to nilSlice = append(nilSlice, 1) fmt.Printf("nilSlice after append: %v\n", nilSlice) // Output: [1]
Conclusion
Go's slices are a fundamental and highly optimized data type for working with sequences of elements. By understanding their underlying array model and the semantics of len
, cap
, append
, and copy
, you gain a powerful tool for building efficient and concise Go programs. Always remember that slices are views, and this core principle will guide you in predicting their behavior and avoiding common pitfalls. Mastering slices is a significant step towards becoming a proficient Go developer.