Go Slices & Memory Leaks

An explanation of common pitfalls when using slices in the Go Programming Language.

Slices are a fundamental data structure in the Go programming language. Slices are a dynamic collection of elements of a given type. For example, a slice might be declared and initialized like this:

exampleSlice := []int{1, 2, 3, 4, 5}

Slices offer several advantages. One of the key advantages is the ability to add elements to a slice without dealing with the tedium of range checking.

exampleSlice := []int{1, 2, 3, 4, 5}
// results in 1, 2, 3, 4, 5, 6
exampleSlice := append(exampleSlice, 6)

The convenience of this data structure is in stark contrast to a plain old array data structure that is not dynamic. Arrays have a set number of elements and once they are used up your only recourse if you want to add a new one is to create a new array. Using slices abstracts all this complexity away. However, this does not mean that you can turn a blind eye to what is happening in the background. In fact, doing so can lead to serious memory leaks. To demonstrate this first we will need to understand the three major components of how slices behave.

Three Components of a Slice

  1. Slices are backed by an array (a contiguous grouping of memory). This is important to understand because it directly relates to memory management and potential leaks.
  2. A slice keeps track of its length. You can think of the length as the number of elements that are stored in the slice. You can use the len function to get the length of a given slice - len(mySlice).
  3. A slice keeps track of its capacity. You can think of the capacity as the number of elements that the slice can hold. You can use the cap function to get the capacity of a given slice - cap(mySlice). Note that we can initialize a slice with a given capacity like so - mySlice := make([]int, 5).

Slice Behavior

When you append an element to a slice, the slice itself checks if the length of the slice is equal to its current capacity. If it is, then new memory will be allocated for a new backing array that has twice the capacity (usually) of the previous capacity. Since Go is a garbage collected programming language then the previous array's memory will be deallocated as long as nothing else is holding a reference to it. It is critical to understand that multiple slices may be pointing to the same underlying array.

firstSlice := []int8{1, 2, 3, 4, 5}
// This creates a new slice that is the first three elements of the first slice.
secondSlice := firstSlice[0:3]
fmt.Printf("Memory address of the first element in the first slice: %p\n", &firstSlice[0])
fmt.Printf("Elements in the first slice: %v\n", firstSlice)
fmt.Printf("Length of the first slice: %d\n", len(firstSlice))
fmt.Printf("Capacity of the first slice: %d\n", cap(firstSlice))
fmt.Printf("Memory address of the first element in the second slice: %p\n", &secondSlice[0])
fmt.Printf("Elements in the second slice: %v\n", secondSlice)
fmt.Printf("Length of the second slice: %d\n", len(secondSlice))
fmt.Printf("Capacity of the second slice: %d\n", cap(secondSlice))
Output: Memory address of the first element in the first slice: 0xc00011a00b Elements in the first slice: [1 2 3 4 5] Length of the first slice: 5 Capacity of the first slice: 5 Memory address of the first element in the second slice: 0xc00011a00b Elements in the second slice: [1 2 3] Length of the second slice: 3 Capacity of the second slice: 5

Notice how the memory address of the first element of both slices is the same. That is because they are both backed by the same array behind the scenes. This may be confusing at first but this is actually a great feature of Go. It means that the creation of a slice does not necessitate the allocation of new memory for that slice's elements. When we define secondSlice we are not creating a new underlying array. Instead we are creating a new slice that is referencing a subset of an existing array. This technique is known as reslicing. Memory allocations being slow, this offers a lot of free performance optimizations to our code when using slices. It is a feature, not a bug.

However, there are situations where not being aware of this behavior will get you into trouble. Consider this example:

func main() {
sourceSlice := []int8{1, 2, 3, 4, 5}
// Print the memory address of the first element in the slice.
fmt.Printf("Memory address of the first element in the source slice: %p\n", &sourceSlice[0])
fmt.Printf("Elements in the source slice: %v\n", sourceSlice)
doWorkWithSlice(sourceSlice[:3])
fmt.Printf("Memory address of the first element in the source slice after call to doWorkWithSlice: %p\n", &sourceSlice[0])
fmt.Printf("Elements in the source slice after call to doWorkWithSlice: %v\n", sourceSlice)
}
// This function simulates some work we might do with a slice
func doWorkWithSlice(slice []int8) {
fmt.Printf("Memory address of the first element in the slice parameter: %p\n", &slice[0])
fmt.Printf("Elements in the slice parameter: %v\n", slice)
fmt.Printf("Length of slice parameter: %d\n", len(slice))
fmt.Printf("Capacity of slice parameter: %d\n", cap(slice))
workingSlice := append(slice, 99)
fmt.Printf("Memory address of the first element in the working slice: %p\n", &workingSlice[0])
fmt.Printf("Elements in the working slice: %v\n", workingSlice)
}
Output: Memory address of the first element in the source slice: 0xc0000120db Elements in the source slice: [1 2 3 4 5] Memory address of the first element in the slice parameter: 0xc0000120db Elements in the slice parameter: [1 2 3] Length of slice parameter: 3 Capacity of slice parameter: 5 Memory address of the first element in the working slice: 0xc0000120db Elements in the working slice: [1 2 3 99] Memory address of the first element in the source slice after call to doWorkWithSlice: 0xc0000120db Elements in the source slice after call to doWorkWithSlice: [1 2 3 99 5]

In this example we passed a slice to the doWorkWithSlice function, where we sliced the original sourceSlice to only include the first three elements. Initially, both the original slice and the passed slice reference the same backing array. When we call append inside the doWorkWithSlice function, Go checks if the capacity of the slice is enough to hold the new element. Since the slice is only of length 3, and the capacity is 5, no new memory allocation occurs, and the new element is added directly in the original backing array. However, because of how slicing works, the original slice sourceSlice is modified, and now contains the newly appended value, even though we only intended to append the value to the smaller workingSlice.

Luckily this is easy enough to avoid with a simple change.

// Previously doWorkWithSlice(sourceSlice[:3])
doWorkWithSlice(sourceSlice[:3:3])

Making this change results in the following output:

Output: Memory address of the first element in the source slice: 0xc00011a00b Elements in the source slice: [1 2 3 4 5] Memory address of the first element in the slice parameter: 0xc00011a00b Elements in the slice parameter: [1 2 3] Length of slice parameter: 3 Capacity of slice parameter: 3 Memory address of the first element in the working slice: 0xc00011a048 Elements in the working slice: [1 2 3 99] Memory address of the first element in the source slice after call to doWorkWithSlice: 0xc00011a00b Elements in the source slice after call to doWorkWithSlice: [1 2 3 4 5]

Notice how the memory address of the working slice is now different from that of the first element of the slice parameter. This change was due to the slice being explicitly re-sliced with sourceSlice[:3:3]. By providing the third parameter to the slice, we are not just slicing the array but also specifying a new capacity for the resulting slice. As a result, when the append operation is called, Go will allocate a new array for the working slice, without modifying the original sourceSlice. This is because when append is called on the slice parameter, the capacity of the slice is not enough to hold the new element, so a new array is allocated.

Memory Leaks

Now that we understand this phenomenon lets look at this example where we read in all the bytes from a huge file but only return a subset of those bytes.

func main() {
fileSubsetSlice, err := getFirstTenBytesOfFile()
if err != nil {
fmt.Println("Error reading in large file.")
}
fmt.Printf("Memory address of the first element in the fileSubsetSlice: %p\n", &fileSubsetSlice[0])
}
func getFirstTenBytesOfFile() ([]byte, error) {
const largeFileName = "largefile.txt"
// Read in the large file
fileBytes, err := os.ReadFile(largeFileName)
if err != nil {
return nil, err
}
fmt.Printf("Memory address of the first element in the fileBytes slice: %p\n", &fileBytes[0])
return fileBytes[:10], nil
}
Output: Memory address of the first element in the fileBytes slice: 0xc000132000 Memory address of the first element in the fileSubsetSlice: 0xc000132000

In this example we are reading in a large file into the fileBytes slice. We then return a subset of that slice (result[10:20]), which creates a new slice pointing to the part of the original array. This is what introduces our memory leak. The issue here is that the returned slice still references the original large fileBytes slice. Even though we're only interested in a small portion of the data, the original, potentially large, underlying array remains in memory because the returned slice retains a reference to it. This prevents the garbage collector from reclaiming the memory used by the entire file’s contents, resulting in a memory leak.

Again avoiding this is easy enough. We simply need to ensure that we don't keep a reference to the original large slice when we're only interested in a small part of it. This can be achieved by making a copy of the subset of the slice (using the copy function) that we actually need before returning it.

// Create a copy of the relevant part of the slice to return
subset := make([]byte, 10)
copy(subset, fileBytes[:10])
return subset, nil

Doing this will remove any references to the large underling array that was used by the fileBytes slice. Once the getFirstTenBytesOfFile function exits the garbage collector can do its thing and deallocate that memory.

In Go, slices are powerful and flexible data structures, but with great power comes the responsibility to understand how they work. As demonstrated throughout this article, when working with slices, it's important to be mindful of how multiple slices can point to the same underlying array. If not handled properly, this can prevent memory from being released when it's no longer needed.

The key to avoiding such memory leaks lies in understanding how slices behave, such as how the capacity of slices affects memory allocation, and how to prevent unintended sharing of backing arrays. Simple techniques, like re-slicing with explicit capacity or copying data into new slices, can prevent these issues and ensure efficient memory management. Always remember that Go's garbage collector only works when there are no active references to memory.

Sources


Written by Daniel Marshall

Published on February 16th 2025