Skip to main content
โšก Calmops

Go Slices and append: A Complete Guide

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]

Resources

Comments