CHAPTER 13 · COMPOSITE TYPES

Slices, Internals & Idioms

Slices are Go's general-purpose sequence. They look simple (a dynamic array with append and slicing syntax), but a few quirks around sharing underlying memory cause the most common bugs in beginner Go code. This chapter explains the internals once, so you can confidently reason about every append.

Learning objectives

  • Explain the three fields of a slice header: pointer, length, capacity.
  • Create slices with literals, make, and slicing.
  • Use append and copy correctly.
  • Recognize aliasing pitfalls when multiple slices share storage.
  • Distinguish nil from an empty slice (and know when it matters).

What a slice is

Under the hood, a slice is a 3-word struct that points to a piece of an underlying array:

SLICE HEADER (24 bytes) ptr → &arr[1] len = 3 cap = 4 UNDERLYING ARRAY 1[0] 2[1] 3[2] 4[3] 5[4] Slice [1:4] with cap up to [3] inclusive, len=3, cap=4, but [4] is beyond cap
A slice is a window into an array: ptr, len, cap.

Creating slices

// Literal
s := []int{1, 2, 3}

// With length (and cap)
t := make([]int, 3)          // len=3, cap=3
u := make([]int, 3, 10)      // len=3, cap=10, grow cheaply up to 10

// Zero-valued
var v []int                  // nil slice, len=cap=0

Slicing syntax

The form is s[low:high:max] (the last one is rare; it caps the new slice's capacity):

arr := [5]int{10, 20, 30, 40, 50}
s  := arr[1:4]         // len=3, cap=4, [20 30 40]
t  := arr[:2]          // [10 20]
u  := arr[3:]          // [40 50]
full := arr[:]         // view of the whole array
bounded := arr[1:3:3]  // len=2, cap=2: capped

append

s := []int{1, 2}
s = append(s, 3)              // [1 2 3]
s = append(s, 4, 5, 6)        // [1 2 3 4 5 6]

other := []int{7, 8}
s = append(s, other...)       // spread a slice into append

Golden rule: always reassign the result of append. If the append triggered a reallocation, the old variable still points to the old backing array.

!
Surprising growth behavior When append grows the slice, Go roughly doubles the capacity for small slices. But the exact pattern is an implementation detail, never rely on a specific growth factor. Use make([]T, 0, N) when you know N up front.

copy

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)       // n == 3 (the min of len(src), len(dst))
fmt.Println(dst, n)       // [1 2 3] 3

copy only copies the overlap, it never grows the destination. It's the right tool for decoupling two slices so changes to one don't affect the other.

Aliasing pitfalls

The most common slice bug: two slices share the same backing array, and modifying one mutates the other:

a := []int{1, 2, 3, 4, 5}
b := a[1:4]                    // shares backing array
b[0] = 99
fmt.Println(a)                 // [1 99 3 4 5] , surprised?

Even scarier, append can sometimes visibly mutate another slice, sometimes not, depending on capacity:

a := []int{1, 2, 3, 4, 5}
b := a[:3]                     // len 3, cap 5
b = append(b, 99)              // fits in cap, mutates a!
fmt.Println(a)                 // [1 2 3 99 5]

Defensive copy when you need independence:

b := make([]int, len(a))
copy(b, a)
// ...or:
b := append([]int(nil), a...)  // idiomatic one-liner

nil vs empty

var a []int            // nil slice
b := []int{}           // empty but NOT nil slice

fmt.Println(len(a), len(b))     // 0 0
fmt.Println(a == nil, b == nil) // true false

// Both can be append()ed to safely:
a = append(a, 1)       // fine

In JSON encoding, a nil slice marshals to null while an empty slice marshals to []. For public APIs, consider which you want. Elsewhere they behave identically.

Multi-dimensional (slices of slices)

grid := [][]int{
    {1, 2, 3},
    {4, 5, 6},
}
fmt.Println(grid[1][2])     // 6

Inner slices can have different lengths (a "jagged" slice). For a true rectangular matrix, prefer []int with manual indexing (grid[i*width+j]) or a library like gonum.org/v1/gonum/mat.

Common idioms

Remove element at index i (preserving order)

s = append(s[:i], s[i+1:]...)

Insert at index i

s = append(s[:i], append([]int{x}, s[i:]...)...)

Filter in place (allocation-free)

kept := s[:0]
for _, v := range s {
    if keep(v) {
        kept = append(kept, v)
    }
}
s = kept

Check your understanding

Practice exercises

EXERCISE 1

Unique elements

Write unique(xs []int) []int that returns a new slice with duplicates removed, preserving order of first occurrence. Use a map[int]bool to track seen elements.

Show solution
func unique(xs []int) []int {
    seen := make(map[int]bool, len(xs))
    out := make([]int, 0, len(xs))
    for _, v := range xs {
        if !seen[v] {
            seen[v] = true
            out = append(out, v)
        }
    }
    return out
}
EXERCISE 2

Rotate left by k

Write rotateLeft(xs []int, k int) []int that returns a new slice rotated left by k positions. rotateLeft([1,2,3,4,5], 2)[3,4,5,1,2]. Handle k > len(xs).

Show solution
func rotateLeft(xs []int, k int) []int {
    if len(xs) == 0 {
        return xs
    }
    k = k % len(xs)
    return append(append([]int{}, xs[k:]...), xs[:k]...)
}

We use append([]int{}, ...) to force a fresh backing array so the result doesn't alias xs.

Further reading

Slices mastered, the #1 type in Go.