Arrays and Slices
Understand Go's fixed-size arrays and dynamic slices. Learn how slices work under the hood, memory layout, capacity vs length, and slice operations.
Basic types like integers and strings let you represent individual values. Real programs need collections—a list of user IDs, a buffer of bytes read from a file, a sequence of temperature readings. You need data structures that hold multiple values of the same type.
Go provides two related types for sequences: arrays and slices. Arrays have fixed size determined at compile time. Slices are dynamic views into arrays, growing and shrinking as needed. Almost all Go code uses slices, but understanding arrays first clarifies how slices work.
Arrays
An array is a fixed-length sequence of elements of the same type. The length is part of the type—an array of 5 integers is a different type than an array of 10 integers.
var numbers [5]int
fmt.Println(numbers) // [0 0 0 0 0]
The zero value of an array initializes all elements to their zero values. For integers, that's zero.
Array literals specify initial values:
primes := [5]int{2, 3, 5, 7, 11}
fmt.Println(primes) // [2 3 5 7 11]
Let the compiler count the elements:
primes := [...]int{2, 3, 5, 7, 11} // Length is 5
The ... syntax tells the compiler to determine the length from the number of elements. The resulting type is still a fixed-size array.
Access elements by index:
primes := [5]int{2, 3, 5, 7, 11}
fmt.Println(primes[0]) // 2
fmt.Println(primes[4]) // 11
primes[2] = 17
fmt.Println(primes) // [2 3 17 7 11]
Array indices start at zero. The first element is primes[0], the last is primes[len(primes)-1]. Accessing beyond the array bounds causes a runtime panic:
fmt.Println(primes[5]) // panic: index out of range
The length of an array is available through len():
fmt.Println(len(primes)) // 5
Arrays Are Values
Arrays in Go are values, not references. When you assign an array to another variable or pass it to a function, the entire array is copied:
a := [3]int{1, 2, 3}
b := a // Copies all 3 elements
b[0] = 100
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]
Modifying b doesn't affect a because b is a separate copy. This differs from arrays in many languages where array variables hold references.
Function calls copy arrays:
func modify(arr [3]int) {
arr[0] = 100
}
a := [3]int{1, 2, 3}
modify(a)
fmt.Println(a) // [1 2 3], unchanged
The function receives a copy of the array. Changes inside the function don't affect the original. For large arrays, this copying is expensive. A 10,000-element array of integers is 80KB of data copied on every function call.
This is why arrays are rare in Go code. Slices solve the copying problem while providing dynamic sizing.
Why Arrays Exist
If arrays are rarely used, why does Go have them? Two reasons:
Arrays provide the foundation for slices. Slices are built on top of arrays. Understanding arrays helps understand slice behavior.
Arrays work well for fixed-size collections where the size is part of the domain. An IPv4 address is always 4 bytes. An MD5 hash is always 16 bytes. RGB color values are always 3 components. Using [4]byte for an IP address makes the size explicit in the type.
But for most cases—lists that grow, buffers that resize, collections of variable length—you want slices.
Slices
A slice is a view into an array. It has three components: a pointer to the array, a length, and a capacity. This three-word structure is what makes slices powerful:
numbers := []int{1, 2, 3, 4, 5}
This creates a slice—note the absence of a size in the brackets. Under the hood, Go creates an array to hold the values and returns a slice that references that array.
The slice's length is the number of elements it contains:
fmt.Println(len(numbers)) // 5
The slice's capacity is the number of elements in the underlying array, starting from the slice's first element:
fmt.Println(cap(numbers)) // 5
Initially, length and capacity are the same. They diverge when you create slices from slices or when slices grow.
Creating Slices
Several ways exist to create slices. Slice literals are most common:
numbers := []int{1, 2, 3, 4, 5}
names := []string{"Alice", "Bob", "Carol"}
The make function creates a slice with specified length and capacity:
s := make([]int, 5) // length 5, capacity 5
fmt.Println(s) // [0 0 0 0 0]
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 5
Separate length and capacity:
s := make([]int, 5, 10) // length 5, capacity 10
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 10
This creates a slice with 5 initialized elements but room for 10 in the underlying array. The extra capacity allows appending elements without allocating a new array immediately.
The zero value of a slice is nil:
var s []int
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0
A nil slice has no underlying array. It's safe to use—reading length, capacity, and ranging over it all work. Appending to a nil slice allocates an array automatically.
Slicing Slices
You can create slices from existing slices or arrays:
numbers := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
slice1 := numbers[2:5] // [2 3 4]
slice2 := numbers[:4] // [0 1 2 3]
slice3 := numbers[6:] // [6 7 8 9]
slice4 := numbers[:] // [0 1 2 3 4 5 6 7 8 9]
The syntax is [low:high], including low and excluding high. Omit low to start from zero. Omit high to go to the end.
These slices share the same underlying array:
numbers := []int{0, 1, 2, 3, 4, 5}
slice := numbers[1:4] // [1 2 3]
slice[0] = 100
fmt.Println(numbers) // [0 100 2 3 4 5]
fmt.Println(slice) // [100 2 3]
Modifying the slice modifies the underlying array, which affects all slices viewing that array. This sharing is efficient but requires awareness—changing a slice can affect other slices.
The Slice Header
Understanding the slice header clarifies this behavior. A slice is a struct with three fields:
type slice struct {
ptr *Element // pointer to first element
len int // number of elements in slice
cap int // capacity of underlying array from ptr
}
When you slice, you create a new header pointing into the same array:
numbers := []int{0, 1, 2, 3, 4, 5} // creates array, returns slice
slice := numbers[2:5] // creates new header
The numbers slice has ptr pointing to numbers[0], len 6, cap 6. The slice has ptr pointing to numbers[2], len 3, cap 4 (from index 2 to the end of the array).
Both headers point into the same array. Changes through one slice appear in the other.
Appending to Slices
The append function adds elements to a slice:
numbers := []int{1, 2, 3}
numbers = append(numbers, 4)
fmt.Println(numbers) // [1 2 3 4]
Append returns a new slice. You must assign the result back to the variable. This pattern is consistent throughout Go—functions that modify slices return new slices rather than modifying in place.
Append multiple elements:
numbers = append(numbers, 5, 6, 7)
fmt.Println(numbers) // [1 2 3 4 5 6 7]
Append another slice using ... to expand it:
more := []int{8, 9, 10}
numbers = append(numbers, more...)
fmt.Println(numbers) // [1 2 3 4 5 6 7 8 9 10]
How Append Works
When you append to a slice with available capacity, append writes to the underlying array and returns a slice with increased length:
s := make([]int, 3, 5) // len 3, cap 5
fmt.Println(len(s), cap(s)) // 3 5
s = append(s, 10)
fmt.Println(len(s), cap(s)) // 4 5
The capacity hasn't changed because the underlying array had room. The length increased to include the new element.
When capacity is exhausted, append allocates a new, larger array, copies existing elements, and returns a slice pointing to the new array:
s := []int{1, 2, 3}
fmt.Println(len(s), cap(s)) // 3 3
s = append(s, 4)
fmt.Println(len(s), cap(s)) // 4 6
The capacity grew from 3 to 6. Append typically doubles capacity when allocating, though the exact strategy varies based on size.
This reallocation means the slice now points to a different array. Any other slices viewing the old array are unaffected:
a := []int{1, 2, 3}
b := a // same underlying array
a = append(a, 4) // may allocate new array
b[0] = 100
fmt.Println(a) // [1 2 3 4]
fmt.Println(b) // [100 2 3]
After appending to a, it points to a new array. Modifying b doesn't affect a.
Slice Gotchas
The shared array behavior creates subtle bugs. Consider this function that appends to a slice:
func addElement(slice []int, value int) []int {
return append(slice, value)
}
s1 := []int{1, 2, 3}
s2 := s1[:2] // [1 2]
s1 = addElement(s1, 4)
s2 = addElement(s2, 5)
fmt.Println(s1) // [1 2 5 4]
fmt.Println(s2) // [1 2 5]
Both s1 and s2 shared the underlying array. Appending to s2 overwrote the third element (which was s1[2]). The result is confusing.
The solution is to avoid sharing when you'll be modifying slices. Copy the data:
s2 := make([]int, len(s1[:2]))
copy(s2, s1[:2])
The copy function copies elements from source to destination, returning the number of elements copied. Now s2 has its own array.
Copy Function
The copy function copies from one slice to another:
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println(dst) // [1 2 3]
fmt.Println(n) // 3
Copy transfers as many elements as fit in the destination. The destination slice isn't extended—if it's too small, only the first elements are copied.
Copy handles overlapping slices correctly:
s := []int{1, 2, 3, 4, 5}
copy(s[2:], s[:3]) // shift elements right
fmt.Println(s) // [1 2 1 2 3]
This safely moves elements within the same slice.
Slice Patterns
Pre-allocate capacity when size is known:
// Bad: multiple allocations
var results []string
for i := 0; i < 1000; i++ {
results = append(results, process(i))
}
// Good: single allocation
results := make([]string, 0, 1000)
for i := 0; i < 1000; i++ {
results = append(results, process(i))
}
Pre-allocating avoids repeated allocations as the slice grows.
Filter elements in place:
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := numbers[:0] // keep same backing array, zero length
for _, n := range numbers {
if n%2 == 0 {
result = append(result, n)
}
}
fmt.Println(result) // [2 4 6 8 10]
This reuses the same array, avoiding allocation. The filtered elements move to the front of the array.
Remove an element by index:
i := 2 // remove element at index 2
slice = append(slice[:i], slice[i+1:]...)
This creates a new slice from everything before index i and everything after, omitting element i. The order is preserved.
For unordered slices, swap with the last element and truncate:
slice[i] = slice[len(slice)-1]
slice = slice[:len(slice)-1]
This is faster because it avoids copying elements.
Nil vs Empty Slices
A nil slice and an empty slice behave similarly but aren't identical:
var nilSlice []int
emptySlice := []int{}
fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false
fmt.Println(len(nilSlice)) // 0
fmt.Println(len(emptySlice)) // 0
Both have length zero. Both work with append and range. The difference is in the underlying array: the nil slice has no array, the empty slice has a zero-length array.
In practice, prefer nil slices for default values:
var data []byte // nil slice
if needsData {
data = readData()
}
Use empty slices when explicitly signaling "no elements but container exists":
func getResults() []Result {
if err != nil {
return nil // error: no results
}
return []Result{} // success: zero results
}
This distinction is subtle and often doesn't matter. Many APIs accept both interchangeably.
Comparing Slices
You cannot compare slices with ==:
a := []int{1, 2, 3}
b := []int{1, 2, 3}
fmt.Println(a == b) // Compile error: invalid operation
The only valid comparison is to nil:
var s []int
fmt.Println(s == nil) // true
To check if two slices have the same contents, loop and compare elements:
func equal(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
The slices package (Go 1.21+) provides slices.Equal for this:
import "slices"
a := []int{1, 2, 3}
b := []int{1, 2, 3}
fmt.Println(slices.Equal(a, b)) // true
Memory and Performance
Slices are efficient. Passing a slice to a function copies only the three-word header (24 bytes on 64-bit systems), not the underlying data. This makes slices cheap to pass around.
However, holding a slice prevents the underlying array from being garbage collected. A slice referencing one element in a huge array keeps the entire array in memory:
func readFile(filename string) []byte {
data, _ := os.ReadFile(filename) // reads entire file
return data[:100] // return first 100 bytes
}
The returned slice keeps the entire file in memory, even though only 100 bytes are used. If this matters, copy to a new slice:
result := make([]byte, 100)
copy(result, data[:100])
return result
Now the large array can be collected.
What's Next
Slices are Go's primary data structure for sequences. The three-word header—pointer, length, capacity—enables efficient manipulation without copying data. Understanding this structure explains append behavior, slicing operations, and memory sharing.
The next composite type is the map—Go's built-in hash table for key-value pairs. Maps complement slices: slices provide ordered sequences with integer indices, maps provide unordered associations with arbitrary keys.
Together, slices and maps handle most data structure needs. Arrays exist but rarely appear in application code. Slices provide the same functionality with flexibility and efficiency.
Ready to explore maps and strings?
