6. Arrays and slices: the internals that explain the bugs
6.1 Arrays are fixed-size value types
var a [3]int // [0 0 0], part of the containing struct/stack frame
b := a // b is a full COPY of a
b[0] = 99 // does not affect a
Arrays are rarely used directly in Go; they exist mostly as the backing
store beneath slices, or where a fixed size is a semantic guarantee (e.g.
[32]byte for a SHA-256 digest).
6.2 A slice is a three-word header
This is the single most important mental model in this chapter. A slice value is not the data — it's a small struct:
type sliceHeader struct {
ptr *T // pointer into a backing array
len int // number of elements currently visible
cap int // capacity of the backing array from ptr onward
}
That means slicing a slice does not copy the underlying data — it creates a new header pointing into the same array:
original := []int{1, 2, 3, 4, 5}
view := original[1:3] // len=2, cap=4, shares backing array
view[0] = 99 // original[1] is now 99 too!
6.3 append and the growth trap
append only allocates a new backing array when the current one lacks
capacity. If it doesn't need to grow, it writes into the existing array
— which can silently corrupt a slice some other variable still references:
a := make([]int, 3, 5) // len=3, cap=5 — room to grow without reallocating
b := append(a, 99) // still within cap, so b shares a's backing array
a = append(a, 42) // ALSO fits — overwrites the slot b just wrote to!
fmt.Println(b) // [.. .. .. 42] — NOT 99. Classic aliasing bug.
The fix, whenever you hand a sub-slice to code that might append to it, is
to force a fresh allocation with a full slice expression
(s[low:high:max], setting cap == len) or with append(s[:0:0], s...).
6.4 Growth strategy and pre-allocation
Go's runtime roughly doubles capacity for small slices and grows by ~1.25x
for large ones (implementation detail, not a spec guarantee — don't rely
on exact numbers, but do rely on "occasional reallocation + copy"). If you
know the final size, make([]T, 0, n) avoids all of the intermediate
reallocations:
// Without pre-allocation: several reallocations as the slice grows
results := []Result{}
for _, item := range items {
results = append(results, process(item))
}
// With pre-allocation: one allocation, zero reallocations
results := make([]Result, 0, len(items))
for _, item := range items {
results = append(results, process(item))
}
6.5 The range loop copies each element
type Big struct{ data [1024]byte }
items := []Big{{}, {}, {}}
for _, item := range items {
item.data[0] = 1 // mutates the COPY, not items[i]
}
// Fix: index into the slice directly, or range with pointers
for i := range items {
items[i].data[0] = 1
}
6.6 War story
A caching layer took a []byte from a sync.Pool, sliced off a header,
handed the rest to a downstream handler as the "body," and returned the
original buffer to the pool for reuse — while the downstream handler was
still reading from a slice that aliased the same backing array the pool
was about to overwrite for the next request. The bug appeared as
intermittent, unreproducible byte corruption in ~1 in 50,000 requests
under load, and took two engineers a full day with the race detector and
-fsanitize-style buffer poisoning before the aliasing was spotted. The
fix was one line: copy the body (bytes.Clone in modern Go) before
returning the buffer to the pool. Lesson: slices are views, not
copies — treat any slice you didn't allocate yourself as borrowed, not
owned, unless the API contract says otherwise.