Slice Internals

A Go slice is not a container — it is a small, fixed-size descriptor of a contiguous region of some backing array. The runtime represents it as a three-word header: a pointer to the first element, an integer length, and an integer capacity (runtime/slice.gotype slice struct { array unsafe.Pointer; len int; cap int }). Because the header is a small value, passing a slice to a function copies the header but shares the backing array; because append may or may not reallocate that array depending on remaining capacity, slices behave like value types in some respects and reference types in others. Almost every “surprising” thing a Go program does with slices — silent aliasing, an append that mutates a caller’s data, an append that doesn’t — is a direct, mechanical consequence of this three-word header and the growth rule, and is fully predictable once you can see them.

Mental Model

Hold three numbers in your head per slice: where it starts (ptr), how many elements you may index (len), and how many elements live in the backing array from ptr onward (cap). The backing array is separate, possibly shared, possibly larger than the slice. A slice is a window onto that array: len is how wide the window is, cap is how far right it could be widened without moving.

flowchart LR
    subgraph H["Slice header (3 words, 24 bytes on 64-bit)"]
        P["ptr →"]
        L["len = 3"]
        C["cap = 5"]
    end
    subgraph A["Backing array (7 elements)"]
        E0["e0"]
        E1["e1"]
        E2["e2 ← ptr"]
        E3["e3"]
        E4["e4"]
        E5["e5"]
        E6["e6"]
    end
    P --> E2
    style E2 fill:#cde
    style E3 fill:#cde
    style E4 fill:#cde

Figure: the header for s := arr[2:5] where arr has 7 elements. ptr points at e2; len=3 so s[0..2] are e2,e3,e4; cap=5 because there are 5 elements (e2..e6) from ptr to the end of the array. The insight: len and cap measure different things from the same starting point, and append can fill in e5,e6 in place but no further.

Mechanical Walk-through

The three-word header

The runtime’s internal type is exactly three words (runtime/slice.go):

type slice struct {
	array unsafe.Pointer // points at element 0 of the window
	len   int            // number of indexable elements
	cap   int            // elements available from array onward
}

On a 64-bit platform that is 24 bytes; on 32-bit, 12 bytes (see Word Size and Architecture Portability). The reflection-visible mirror of this is reflect.SliceHeader (now deprecated in favour of unsafe.Slice/unsafe.SliceData), whose fields the Go Slices blog presents as {Length, Capacity, ZerothElement} (Go blog — Arrays, slices: the mechanics of ‘append’). The zero value of a slice is {nil, 0, 0} — the nil slice — which is distinct from an empty non-nil slice {ptr, 0, 0}; len, range, indexing, and append all behave identically on the two, but s == nil distinguishes them and JSON marshals a nil slice as null and an empty slice as [].

Creating a slice

A slice comes into existence one of four ways. make([]T, len, cap) allocates a fresh, hidden backing array of cap elements (zeroed — see Zero Values and Memory Initialization) and returns a header with the given len and cap; the spec says “a slice created with make always allocates a new, hidden array” (Go spec). A composite literal []T{...} builds the backing array from the listed elements and points a header at it. A slice expression on an array, pointer-to-array, string, or another slice produces a header that aliases the operand’s storage. And var s []T gives the nil header.

Slice expressions and full-slice expressions

The two-index expression a[low:high] yields a slice with len = high - low and cap = cap(a) - low (for an array operand, cap(a) is the array length); the spec requires 0 ≤ low ≤ high ≤ cap(a) or it panics (Go spec — Slice expressions). The crucial, often-missed point: slicing does not clamp capacity to high — the new slice still “sees” the rest of the backing array up to cap. That residual capacity is exactly what lets a later append reach past high and silently overwrite elements another slice still references.

The full slice expression (three-index) a[low:high:max] exists to cut that off. It sets len = high - low and cap = max - low, with the constraint 0 ≤ low ≤ high ≤ max ≤ cap(a) (Go spec). By choosing max == high you produce a slice whose cap == len, so the next append is guaranteed to reallocate rather than touch the shared array. This is the standard defensive tool for handing out a sub-slice that the caller may safely append to.

append and the growth rule

append(s, x...) first computes the required length n = len(s) + len(x...). If n ≤ cap(s), it writes the new elements into the existing backing array at indices len(s)..n-1, and returns a header with the same ptr and cap but len = nmutating the shared array in place. If n > cap(s), it calls runtime.growslice, which allocates a new backing array, copies the old elements over with memmove, writes the new ones, and returns a header pointing at the new array — the old array is left untouched and the original slice (and any aliases) still see the old data.

The new capacity is chosen by runtime.nextslicecap. The verbatim logic (runtime/slice.go):

func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		return newLen
	}
	const threshold = 256
	if oldCap < threshold {
		return doublecap
	}
	for {
		// Transition from growing 2x for small slices
		// to growing 1.25x for large slices.
		newcap += (newcap + 3*threshold) >> 2
		if uint(newcap) >= uint(newLen) {
			break
		}
	}
	if newcap <= 0 {
		return newLen
	}
	return newcap
}

Reading it: if a single append needs more than double the current capacity, the slice grows straight to exactly newLen. Otherwise, while the old capacity is below the threshold of 256 elements, capacity doubles. Past 256, each step grows by (newcap + 768) >> 2 — roughly newcap * 1.25 + 192, a smooth taper from 2× toward 1.25× so large slices don’t waste half their memory. The runtime comment names this directly: “Transition from growing 2x for small slices to growing 1.25x for large slices.” After nextslicecap picks a raw capacity, growslice rounds it up to a size class of the allocator (roundupsize), so the final cap you observe is often larger than the formula’s output — the array fills the allocator’s span.

Code Examples

Example 1 — aliasing: an append that reaches back in time

package main
 
import "fmt"
 
func main() {
	base := []int{0, 1, 2, 3, 4} // len 5, cap 5
	a := base[0:3]               // len 3, cap 5  -- a sees the whole array
	b := base[3:5]               // len 2, cap 2
 
	a = append(a, 99) // cap(a)=5 > len 3, so this writes base[3] IN PLACE
	fmt.Println(base) // [0 1 2 99 4]   <-- base mutated
	fmt.Println(b)    // [99 4]         <-- b mutated too, it aliased base[3]
}
  • a := base[0:3]len=3 but cap=5: a still sees base[3] and base[4].
  • append(a, 99) — needed length 4 ≤ cap 5, so no reallocation; 99 lands in base[3].
  • base and b both observe the mutation. a “appended” but actually overwrote a neighbour’s element. This is the canonical append surprise.

Example 2 — the full-slice expression as a fix

func headers(buf []byte) []byte {
	// Hand out the first 8 bytes, but make append on the result safe:
	return buf[0:8:8] // len 8, cap 8  -- next append MUST reallocate
}
 
func main() {
	buf := make([]byte, 64)
	h := headers(buf)        // len 8, cap 8
	h = append(h, 'x')       // cap exceeded -> new array; buf is untouched
	_ = buf
}
  • buf[0:8:8] — the third index max=8 forces cap == len == 8.
  • The caller’s append cannot fit, so growslice allocates a fresh array; buf[8] onward is safe from the caller. Without the :8, cap would be 64 and the append would scribble into buf.

Example 3 — growth observed, and append’s return value

func main() {
	var s []int // nil slice: ptr=nil len=0 cap=0
	for i := 0; i < 10; i++ {
		old := cap(s)
		s = append(s, i) // MUST assign the result
		if cap(s) != old {
			fmt.Printf("len=%d grew cap %d -> %d\n", len(s), old, cap(s))
		}
	}
	// typical: cap 0->1, 1->2, 2->4, 4->8, 8->16  (doubling, then rounded to size classes)
}
  • var s []int starts nil; append to a nil slice works because growslice treats oldCap=0 and allocates.
  • s = append(s, i) — the assignment is mandatory. append returns a new header; if you discard it, len (and possibly ptr) of your variable are stale. Discarding the return is the single most common slice bug.
  • The printed jumps show doubling under the 256 threshold, with the actual cap snapped up to the allocator’s size class.

Example 4 — pre-sizing to avoid repeated growslice

// Bad: 1000 appends, ~10 reallocations + copies.
out := []int{}
for _, v := range in {
	out = append(out, transform(v))
}
 
// Good: one allocation.
out := make([]int, 0, len(in)) // len 0, cap len(in)
for _, v := range in {
	out = append(out, transform(v)) // never reallocates
}

When the final size is known, make([]T, 0, n) allocates once and every append hits the in-place fast path. This is the highest-value slice optimization in hot loops.

Failure Modes / Common Misunderstandings

Discarding append’s return value. append(s, x) without s = compiles (the result is just unused if the line is a statement — actually it is a compile error to call append and ignore the result only if used as an expression; as a bare statement append(s,x) is an error: “append(…) evaluated but not used”). The realistic bug is t := append(s, x) then continuing to use s: s is now stale.

append aliasing a caller’s slice. Passing a slice to a function and append-ing inside it may mutate the caller’s backing array (Example 1). If the function must not affect the caller, it should copy into a fresh slice, or the caller should hand out a full-slice-expression slice with cap == len.

Slices keep big arrays alive. A tiny slice taken from a huge array — small := huge[0:1] — retains a pointer to the whole huge backing array, so the garbage collector cannot reclaim it. To release the bulk, copy: small := append([]T(nil), huge[0:1]...) or slices.Clone(huge[0:1]). This is a frequent memory leak in parsers that slice into a large input buffer.

copy copies min(len(dst), len(src)). copy is bounded by lengths, not capacities, and copies the minimum of the two — a copy into a zero-length slice copies nothing even if cap is large. See copy Semantics Surprises.

Nil vs empty slice. len/range/append treat them identically, but == nil and JSON marshalling distinguish them. Don’t compare slices for equality with == (only == nil is legal); use slices.Equal.

Three-index bounds. a[low:high:max] panics unless low ≤ high ≤ max ≤ cap(a). A common mistake is a[low:high:len(a)] on a slice whose cap > len — fine — versus on an array.

Alternatives and When to Choose Them

Arrays ([N]T) — fixed length is part of the type, the value is the data (no header, no sharing), and assignment/passing copies every element. Choose an array when the size is a compile-time constant and you want value semantics or stack allocation; choose a slice for anything dynamic. See Array Internals.

container/list — a doubly linked list. Almost always worse than a slice for cache locality; choose it only when you need O(1) splice in the middle and stable element addresses.

map[int]T as a sparse “slice” — when indices are sparse or the structure must grow at arbitrary indices. Loses contiguity and ordering. See Map Internals.

The slices package (standard since Go 1.21) — slices.Clone, slices.Insert, slices.Delete, slices.Grow, slices.Equal, slices.Sort. Prefer these over the hand-rolled “slice tricks”; slices.Grow(s, n) is the explicit, readable way to pre-extend capacity.

Production Notes

The pre-size-with-make idiom is the most impactful slice practice in throughput-sensitive Go: every avoided growslice saves an allocation, a memmove, and GC pressure. The Go Slices blog and the community SliceTricks wiki (go.dev/wiki/SliceTricks) document the canonical in-place delete (s = append(s[:i], s[i+1:]...)) and filter-in-place (s[:0] reuse) patterns — all of which exploit the in-place append fast path.

The “small slice pins a large array” leak is a recurring production incident, especially in HTTP servers and protocol parsers that req.Body-read into one big []byte and then retain small header sub-slices: the whole megabyte buffer stays live for the lifetime of the smallest retained field. The fix — slices.Clone the bits you keep — is cheap and should be a code-review reflex. Conversely, the s[:0] capacity-reuse trick (clearing length, keeping the backing array) underpins zero-allocation buffer reuse in libraries like bytes.Buffer and is why sync.Pool-ed buffers are reset with b = b[:0].

Finally, because append is one of the most-executed builtins in Go programs, the runtime’s growth tuning matters: the 256-element threshold and the 2×→1.25× taper were chosen to keep small slices cheap to grow while bounding the wasted memory of large ones, and growslice’s rounding to allocator size classes means the observed cap is an allocator artifact, not a pure function of the formula. See Go Memory Allocator and Size Classes and Span Management.

See Also