Skip to main content

Go Slices and append: A Complete Guide

Created: February 20, 2020 6 min read

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. See Go Installation Guide, Go Ecosystem Overview, Go Best Practices for more context.

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

Share this article

Scan to read on mobile