Introduction
Slices are Go’s primary dynamic array type. Unlike arrays, slices can grow and shrink. The append function is the standard way to add elements to a slice. Understanding how slices and append work internally helps you write efficient Go code and avoid subtle bugs.
Slice Internals
A slice is a three-field struct under the hood:
type slice struct {
array unsafe.Pointer // pointer to underlying array
len int // number of elements
cap int // capacity of underlying array
}
// Create a slice
s := []int{1, 2, 3}
fmt.Println(len(s), cap(s)) // => 3 3
// Create with make (length, capacity)
s2 := make([]int, 3, 10)
fmt.Println(len(s2), cap(s2)) // => 3 10
// Slice of a slice shares the underlying array
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // [2, 3]
fmt.Println(len(b), cap(b)) // => 2 4 (cap from index 1 to end of a)
The append Function
append adds elements to the end of a slice and returns the updated slice:
// Append single elements
x := []int{1, 2, 3}
x = append(x, 4)
x = append(x, 5, 6, 7)
fmt.Println(x) // => [1 2 3 4 5 6 7]
// Always assign the result back โ append may return a new slice
x = append(x, 8) // correct
append(x, 9) // wrong โ result discarded
Appending a Slice to a Slice
Use the ... spread operator to append all elements of one slice to another:
x := []int{1, 2, 3}
y := []int{4, 5, 6}
// Append all elements of y to x
x = append(x, y...)
fmt.Println(x) // => [1 2 3 4 5 6]
// Without ..., it won't compile:
// x = append(x, y) // ERROR: cannot use y (type []int) as type int
How append Handles Capacity
When the slice has enough capacity, append adds elements in place. When capacity is exceeded, Go allocates a new, larger array:
s := make([]int, 0, 3) // length 0, capacity 3
fmt.Println(len(s), cap(s)) // => 0 3
s = append(s, 1, 2, 3)
fmt.Println(len(s), cap(s)) // => 3 3 (fits in existing capacity)
s = append(s, 4) // exceeds capacity โ new array allocated
fmt.Println(len(s), cap(s)) // => 4 6 (capacity doubled)
s = append(s, 5, 6)
fmt.Println(len(s), cap(s)) // => 6 6
s = append(s, 7) // exceeds capacity again
fmt.Println(len(s), cap(s)) // => 7 12 (doubled again)
Capacity growth strategy: Go typically doubles capacity for small slices and grows by ~1.25x for larger ones. The exact strategy varies by Go version.
The Sharing Problem
When two slices share the same underlying array, modifying one can affect the other:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // b shares a's array
b[0] = 99
fmt.Println(a) // => [99 2 3 4 5] โ a was modified!
fmt.Println(b) // => [99 2 3]
// append within capacity also modifies the shared array
b = append(b, 100)
fmt.Println(a) // => [99 2 3 100 5] โ a[3] was overwritten!
Fix: Use copy or a full slice expression to prevent sharing:
// Option 1: copy to a new slice
b := make([]int, 3)
copy(b, a[:3])
// Option 2: three-index slice (limits capacity, forces new array on append)
b := a[:3:3] // len=3, cap=3 โ append will allocate new array
b = append(b, 100)
fmt.Println(a) // => [1 2 3 4 5] โ a is unchanged
Common Patterns
Building a Slice Incrementally
// Pre-allocate when you know the size
result := make([]int, 0, len(input))
for _, v := range input {
if v > 0 {
result = append(result, v)
}
}
Removing an Element
// Remove element at index i (order preserved)
func remove(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}
// Remove element at index i (order not preserved โ faster)
func removeUnordered(s []int, i int) []int {
s[i] = s[len(s)-1]
return s[:len(s)-1]
}
s := []int{1, 2, 3, 4, 5}
s = remove(s, 2)
fmt.Println(s) // => [1 2 4 5]
Inserting an Element
func insert(s []int, i int, val int) []int {
s = append(s, 0) // grow by 1
copy(s[i+1:], s[i:]) // shift elements right
s[i] = val
return s
}
s := []int{1, 2, 4, 5}
s = insert(s, 2, 3)
fmt.Println(s) // => [1 2 3 4 5]
Deduplication
func unique(s []int) []int {
seen := make(map[int]bool)
result := s[:0] // reuse the same underlying array
for _, v := range s {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
s := []int{1, 2, 2, 3, 3, 3, 4}
fmt.Println(unique(s)) // => [1 2 3 4]
Filter In-Place
// Filter without allocating a new slice
func filter(s []int, keep func(int) bool) []int {
n := 0
for _, v := range s {
if keep(v) {
s[n] = v
n++
}
}
return s[:n]
}
s := []int{1, -2, 3, -4, 5}
s = filter(s, func(v int) bool { return v > 0 })
fmt.Println(s) // => [1 3 5]
Stack (LIFO)
// Push
stack = append(stack, value)
// Pop
value, stack = stack[len(stack)-1], stack[:len(stack)-1]
// Peek
top := stack[len(stack)-1]
Queue (FIFO)
// Enqueue
queue = append(queue, value)
// Dequeue
value, queue = queue[0], queue[1:]
Performance Tips
Pre-allocate When Size is Known
// Slow: many reallocations
result := []int{}
for i := 0; i < 10000; i++ {
result = append(result, i)
}
// Fast: single allocation
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}
Avoid Unnecessary Copies
// Passing a slice to a function doesn't copy the data
func process(s []int) {
// s is a slice header (24 bytes), not a copy of the data
for i := range s {
s[i] *= 2 // modifies the original
}
}
// To prevent modification, pass a copy
func processSafe(s []int) {
s = append([]int{}, s...) // copy
for i := range s {
s[i] *= 2
}
}
Benchmark: append vs pre-allocated
func BenchmarkAppend(b *testing.B) {
for i := 0; i < b.N; i++ {
s := []int{}
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
func BenchmarkPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
// BenchmarkPrealloc is typically 2-3x faster
Nil Slices vs Empty Slices
var nilSlice []int // nil slice
emptySlice := []int{} // empty slice (non-nil)
fmt.Println(nilSlice == nil) // => true
fmt.Println(emptySlice == nil) // => false
fmt.Println(len(nilSlice)) // => 0
fmt.Println(len(emptySlice)) // => 0
// append works on nil slices
nilSlice = append(nilSlice, 1, 2, 3)
fmt.Println(nilSlice) // => [1 2 3]
// JSON encoding difference
json.Marshal(nilSlice) // => null
json.Marshal(emptySlice) // => []
Summary
| Operation | Code |
|---|---|
| Append element | s = append(s, v) |
| Append slice | s = append(s, other...) |
| Pre-allocate | s = make([]int, 0, n) |
| Copy | copy(dst, src) |
| Remove at i | append(s[:i], s[i+1:]...) |
| Insert at i | append(s[:i], append([]T{v}, s[i:]...)...) |
| Stack push | stack = append(stack, v) |
| Stack pop | v, stack = stack[len-1], stack[:len-1] |
Comments