Frontend Labs
⋯

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.


Generated from real-life-golang-guide.md · 8 Oct 2026 16:36