Manipulating slices in Go involves creating new views, expanding sequences, and explicitly copying data. It may feel more manual than in other languages, but it also makes it very clear what happens in memory.
In the previous article, we saw that slices are views of an underlying array. Now it’s time to expand, copy, and delete elements.
Although arrays have a fixed size, append can return a slice backed by a new array when it needs to grow. We’ll learn the essential operations for adding, copying, and deleting.
The append function: dynamic growth
The append function is the primary tool for adding elements to the end of a slice.
Conceptually, it receives a slice and one or more values of the same type. It then returns the updated descriptor of the slice.
package main
import "fmt"
func main() {
var numbers []int // Nil slice (len=0, cap=0)
// Add one element
numbers = append(numbers, 10)
// Add multiple elements at once
numbers = append(numbers, 20, 30, 40)
fmt.Println(numbers) // [10 20 30 40]
}It’s a very common mistake to write append(numbers, 10) without assigning the result.
Because append can change the underlying array (if it needs to allocate more capacity), you must always use the value it returns:
slice = append(slice, value)
How does memory work under the hood?
When you perform an append:
- If there is extra capacity in the underlying array, Go simply increases the length (
len) and places the value in the empty slot. This is very fast. - If there is NO capacity (the array is full), Go creates a new array with more capacity, copies the old data to the new one, adds the element, and returns a slice pointing to this new array.
Concatenating two slices
If you want to append an entire slice to the end of another, we use the “unpacking” operator ... (ellipsis).
s1 := []int{1, 2, 3}
s2 := []int{4, 5}
// s1 = append(s1, s2) // ERROR: s2 is not an int
s1 = append(s1, s2...) // CORRECT: We unpack s2
fmt.Println(s1) // [1 2 3 4 5]The copy function: duplicating data
As we saw, sliceA := sliceB does not copy the data, only the pointer. If we want two independent copies (to modify one without affecting the other), we use the copy function.
Syntax: copy(destination, source)
Important concept: copy only copies the minimum number of elements between the length of destination and source.
func main() {
source := []int{1, 2, 3, 4, 5}
// CAUTION: Destination must have length (space) allocated
destination := make([]int, 3) // len=3
elementsCopied := copy(destination, source)
fmt.Println(destination) // [1 2 3] (Only 3 fit)
fmt.Println("Copied:", elementsCopied) // 3
}If we had done destination := make([]int, 0) or var destination []int, the copy function would do nothing because the destination length would be 0.
Slicing
We’re already familiar with the [:] operator. Let’s look at some common patterns for manipulating sequences.
Let a := []int{0, 1, 2, 3, 4, 5}:
| Operation | Syntax | Result |
|---|---|---|
| Remove the first | a = a[1:] | [1 2 3 4 5] |
| Remove the last | a = a[:len(a)-1] | [0 1 2 3 4] |
| Take the first N | a = a[:n] | [0 ... n-1] |
Deleting an element
Since Go 1.21, the standard slices package offers slices.Delete. It receives the slice and a half-open interval [i:j) and returns the slice without those elements:
import "slices"
letters := []string{"A", "B", "C", "D", "E"}
letters = slices.Delete(letters, 2, 3)
fmt.Println(letters) // [A B D E]Besides shifting elements, slices.Delete zeroes out the discarded part of the underlying array. This prevents retaining objects through pointers that are no longer visible from the slice.
It’s also good to know the manual pattern, as it explains what happens under the hood. To delete the element at index i, concatenate what comes before and after i:
func main() {
letters := []string{"A", "B", "C", "D", "E"}
deleteIndex := 2 // We want to delete "C"
// letters[:2] -> ["A", "B"]
// letters[3:] -> ["D", "E"]
letters = append(letters[:deleteIndex], letters[deleteIndex+1:]...)
fmt.Println(letters) // ["A", "B", "D", "E"]
}Shared data and retained references
This method usually reuses the underlying array but has two side effects. By moving elements to fill the hole, other slices sharing that array will see the changes. Additionally, the last slot ends up outside the length but retains its value; if it contains references and the array will remain alive, it’s a good idea to zero it out.
Fast deletion (without preserving order)
If you don’t care about the order of elements, there’s a much faster (O(1)) way to delete:
- Copy the last element to the position you want to delete.
- Remove the last element (reduce the slice).
letters := []string{"A", "B", "C", "D", "E"}
i := 2 // Delete "C"
letters[i] = letters[len(letters)-1] // Copy "E" into "C"'s position
letters[len(letters)-1] = "" // Release the discarded reference
letters = letters[:len(letters)-1] // Reduce the length
// Result: ["A", "B", "E", "D"] -> Order changed, but very fast.