Go slice gotchas

Redowan Delowar February 6, 2025
Source

Just like any other dynamically growable container structure, Go slices come with a few gotchas. I don't always remember all the rules I need to be aware of. So this is an attempt to list some of the most common mistakes I've made at least once.

Slices are views over arrays

In Go, a slice is a lightweight wrapper around an array. Instead of storing data itself, it keeps track of three things: a pointer to an underlying array where the data is stored, the number of elements it currently holds, and the total capacity before it needs more space. The Go runtime defines it like this:

When you create a slice from an array or another slice, Go doesn't copy the data - it simply points to a section of the existing array.

This makes slices efficient. Passing a slice by value doesn't mean copying all its elements - only the small slice struct gets copied, while the data stays where it is. But this behavior is also the source of much confusion. The next sections cover some common pitfalls.

Sliced slices share the underlying array

Reslicing a slice doesn't copy data. The newly created slices point to the same array. So modifying one slice will affect others.

Solution: To get independent slices, you need to explicitly copy the data. Use make to create a new slice and copy to transfer the elements.

Append may reallocate

append reallocates the underlying array if capacity is insufficient, changing the backing array pointer.

When passing slices to functions, reallocation inside the function won't update the original slice header in the caller unless the slice is returned and reassigned. Modifications within the capacity are visible.

If you create a slice with a predefined capacity and start appending elements, everything looks fine until you exceed that capacity. Once that happens, Go reallocates memory and moves the slice to a new backing array.

The same behavior applies when passing a slice to a function. If the function modifies elements within the allocated capacity, those changes persist and are visible from outside the function. But if append triggers a reallocation inside the function, the caller's slice remains unchanged.

Solution: If append inside a function reallocates memory, the caller won't see the change. To make it explicit, return the modified slice and reassign it.

Append returns new slice

append returns a new slice. If you don't reassign the result back to the original slice variable, the slice remains unchanged after the append operation. We already saw this in the last section but I think it deserves a section of its own.

Solution: Remember to always assign the return value of append back to the slice variable you are working with.

Nil and empty slices differ

Nil slices have nil array pointers; empty slices have initialized, non-nil pointers and zero length. While often interchangeable for emptiness checks, the distinction matters in certain contexts like JSON encoding or API interactions.

Solution: When you need a truly empty slice (e.g., to represent an empty list in JSON), initialize it as an empty slice (e.g., []int{} or make([]int, 0)). For general emptiness checks, len(slice) == 0 works for both nil and empty slices.

Slicing can leak memory

Small slices created from large arrays can keep the entire large array in memory.

Solution: To avoid memory leaks, copy the data of the small slice into a new, independent slice. This allows the large underlying array to be garbage collected if no longer referenced elsewhere.

Range copies values

for...range on value types iterates over copies. Modifications to the loop variable don't change the original slice.

Solution: If you need to modify slice elements during iteration, use an index-based for loop. This provides direct access to each element via its index.

Make with length initializes

make([]T, length, capacity) initializes the first length elements with the zero value of T. This can be a subtle point if you expect an uninitialized slice of a certain size.

Solution: If you want an empty slice with a specific capacity but without initial zero values, use make([]T, 0, capacity). Or use the slice literal []T{} syntax if you don't care about the capacity.

If you need a slice of a certain length initialized with zero values, make([]T, length, capacity) is the correct approach.

Overlapping copy is tricky

copy(dst, src) with overlapping slices can corrupt data when dst starts inside src.

Solution: To avoid corruption, just don't do it. If you have to, then one way to fix it is by using a temporary buffer. Even then it's messy.

Copy truncates silently

copy also returns the number of elements copied, which is the smaller of len(dst) and len(src). If dst is shorter, data gets truncated.

Solution: On dst, always set the length from the src while copying.

I may have missed, forgotten, or not yet encountered a few other gotchas. If you've run into any that aren't listed here, I'd love to hear about them.

Discussion in the ATmosphere

Loading comments...