Slices: The Three-Word Header That Explains Everything

Arrays are values; slices are a window onto an array. Once you see the pointer-length-capacity header, append's reallocation and the shared-backing-array aliasing gotcha stop being mysteries. The most gotcha-heavy topic in Go, taught by running it. Compiled against Go 1.26.5.

Slices are the single most misunderstood thing in Go, and nearly every slice bug comes from the same missing picture: a slice is not a container, it’s a view onto one. Get that picture right — three fields, one of which points at memory you might be sharing — and the surprising behaviors turn into consequences you can predict. This chapter builds the picture and then runs the surprises, because the only way this really clicks is watching it happen.

Everything here is compiled and run against Go 1.26.5.

Arrays: fixed-size values

Before slices, the thing they’re a view onto. An array has a length fixed at compile time, baked into its type, and it is a value: assigning it, or passing it to a function, copies the whole thing.

package main

import "fmt"

func main() {
	// Arrays are values. Assignment COPIES the whole array.
	a := [3]int{1, 2, 3}
	b := a // full copy
	b[0] = 99

	fmt.Println("a:", a) // unchanged
	fmt.Println("b:", b)

	// Length is part of the type: [3]int and [4]int are different types.
	fmt.Printf("type of a: %T\n", a)

	// A function receives a copy too.
	zero(a)
	fmt.Println("a after zero(a):", a)
}

func zero(arr [3]int) {
	for i := range arr {
		arr[i] = 0
	}
}
$ go run .
a: [1 2 3]
b: [99 2 3]
type of a: [3]int
a after zero(a): [1 2 3]

Writing b[0] = 99 left a untouched, and zero(a) zeroed a copy that vanished when the function returned. The length is part of the type — [3]int and [4]int are as distinct as int and string, and a function taking [3]int cannot be handed a [4]int. That rigidity is exactly why you rarely use arrays directly. They’re the fixed storage underneath; the thing you actually pass around is a slice.

The slice header: pointer, length, capacity

A slice is a small three-field struct — a header — that describes a stretch of some backing array:

  • a pointer to the first element it can see,
  • a length: how many elements it currently covers,
  • a capacity: how many elements exist from its start to the end of the backing array.

That’s the whole model. The slice header is a value (copying a slice copies those three fields), but the pointer means every copy looks at the same underlying elements. Hold that thought; it’s the source of every aliasing surprise later.

You build a slice with a literal ([]int{1, 2, 3}), by slicing an array or another slice, or with make, which lets you set length and capacity separately:

package main

import "fmt"

func main() {
	// make([]T, len, cap): length 0, capacity 4.
	s := make([]int, 0, 4)
	fmt.Printf("start   len=%d cap=%d ptr=%p\n", len(s), cap(s), s)

	prev := cap(s)
	for i := 0; i < 10; i++ {
		s = append(s, i)
		if cap(s) != prev {
			fmt.Printf("grew at len=%d: cap %d -> %d ptr=%p\n", len(s), prev, cap(s), s)
			prev = cap(s)
		}
	}
	fmt.Println("final:", s)
}
$ go run .
start   len=0 cap=4 ptr=0x3615fc326100
grew at len=5: cap 4 -> 8 ptr=0x3615fc32a240
grew at len=9: cap 8 -> 16 ptr=0x3615fc39c080
final: [0 1 2 3 4 5 6 7 8 9]

This one run shows the whole life of append. We asked for capacity 4. The first four appends fit — length climbs, capacity and pointer stay put, so nothing prints. On the fifth append there’s no room, so Go allocates a new, larger backing array, copies the elements over, and returns a slice pointing at it. Watch the pointer: 0x...326100 to 0x...32a240 to 0x...39c080, a different address each time capacity grows. And capacity doubled, 4 to 8 to 16 — the common growth strategy for small slices, which amortizes the cost of copying so that appending N elements is O(N) overall, not O(N²).

Two lessons fall out of that. First, append may or may not reallocate, and you can’t tell by looking. Second — and this is why append’s signature returns a slice — you must assign the result back: s = append(s, x). If a reallocation happened, the old s header still points at the old, now-stale array. Forgetting the assignment is one of the first mistakes everyone makes.

Slice expressions and the aliasing gotcha

You can take a slice of a slice with s[i:j] — start at index i, run up to but not including j. The result is a new header over the same backing array. No copy happens. And that is where people get hurt, because two slices over one array are two windows onto shared memory: write through one, and the other sees it.

package main

import "fmt"

func main() {
	base := []int{0, 1, 2, 3, 4, 5}

	// Two slices over the SAME backing array.
	left := base[0:3]  // {0,1,2}
	right := base[2:5] // {2,3,4}  -- index 2 is shared

	fmt.Println("left: ", left)
	fmt.Println("right:", right)

	// Write through one, see it through the other AND through base.
	left[2] = 99
	fmt.Println("after left[2]=99")
	fmt.Println("left: ", left)
	fmt.Println("right:", right) // right[0] changed too
	fmt.Println("base: ", base)

	// append can also stomp the shared array when capacity allows.
	small := base[0:2]         // len 2, cap 6 (shares base)
	small = append(small, 777) // writes into base[2]!
	fmt.Println("after append to small")
	fmt.Println("small:", small)
	fmt.Println("base: ", base)
}
$ go run .
left:  [0 1 2]
right: [2 3 4]
after left[2]=99
left:  [0 1 99]
right: [99 3 4]
base:  [0 1 99 3 4 5]

Read the output slowly. left covers indices 0–2 of base and right covers 2–4, so they overlap at index 2. Writing left[2] = 99 changes that one element of the backing array, and it shows up three ways at once: as left[2], as right[0] (same physical slot), and as base[2]. Nobody copied anything; there is only one 99 in memory, seen through three windows.

The append case is subtler and nastier. small := base[0:2] has length 2 but capacity 6, because it can see all the way to the end of base. So append(small, 777) has room without reallocating, and it writes 777 into base[2] — clobbering data that logically belonged to base, left, and right. This is the classic slice bug: you append to a slice you carved out of a larger one, and silently corrupt the parent. The fix, when you need independence, is to force a copy.

copy, and the full-slice expression

When you want a slice that does not share memory, copy gives you one. It copies element-by-element into a destination you already sized, and returns how many it moved (the minimum of the two lengths):

package main

import "fmt"

func main() {
	// copy: independent backing arrays, no aliasing.
	src := []int{1, 2, 3}
	dst := make([]int, len(src))
	n := copy(dst, src)
	dst[0] = 99
	fmt.Printf("copied %d elements\n", n)
	fmt.Println("src:", src) // unchanged
	fmt.Println("dst:", dst)

	// The nil slice: usable as-is.
	var s []int
	fmt.Printf("nil slice: len=%d cap=%d nil?=%t\n", len(s), cap(s), s == nil)
	s = append(s, 1, 2) // append to nil works
	fmt.Println("after append:", s, "nil?", s == nil)
}
$ go run .
copied 3 elements
src: [1 2 3]
dst: [99 2 3]
nil slice: len=0 cap=0 nil?=true
after append: [1 2] nil? false

dst now has its own backing array, so dst[0] = 99 leaves src alone. When you carve a subslice you intend to append to independently, you can also cap it at the point of slicing with the full-slice expression base[i:j:k], where k sets the capacity boundary — set k == j and any append is forced to reallocate rather than reach into the parent. That’s the surgical fix for the aliasing bug above when a plain copy is more than you want.

The nil slice is a real slice

The output also shows something reassuring. A slice declared but never initialized is nil — its pointer is nil, length and capacity 0. Unlike a nil map (next chapter) or a nil pointer, a nil slice is fully usable: len and cap return 0, ranging over it iterates zero times, and append works on it, allocating a backing array on first use. So var s []T is the idiomatic way to start an empty, growable slice — you do not need make unless you want to preset a capacity. Note that after the first append, s is no longer nil; if you specifically care about the nil-versus-empty distinction (some JSON encoders do), test len(s) == 0 rather than s == nil.

Final thoughts

A slice is a three-word header — pointer, length, capacity — over a backing array that it may share with other slices. Arrays are fixed-size values that copy on assignment; slices are cheap views that don’t. append grows a slice, and when capacity runs out it silently allocates a fresh array and returns a new header, which is why you always write s = append(s, x). Two slices over one array alias each other, so a write through one is visible through the other, and an append with spare capacity can reach into a parent slice and corrupt it — the bug this chapter exists to inoculate you against. When you need isolation, copy or the three-index slice expression gives it to you. Once you’re reading a slice as a window rather than a box, the surprises stop being surprising.

Next: maps — the comma-ok idiom, randomized iteration order, and the nil-map write that panics.

Comments