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.go —
type 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; becauseappendmay 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, anappendthat mutates a caller’s data, anappendthat 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 = n — mutating 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=3butcap=5:astill seesbase[3]andbase[4].append(a, 99)— needed length 4 ≤ cap 5, so no reallocation;99lands inbase[3].baseandbboth 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 indexmax=8forcescap == len == 8.- The caller’s
appendcannot fit, sogrowsliceallocates a fresh array;buf[8]onward is safe from the caller. Without the:8,capwould be64and theappendwould scribble intobuf.
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 []intstarts nil;appendto a nil slice works becausegrowslicetreatsoldCap=0and allocates.s = append(s, i)— the assignment is mandatory.appendreturns a new header; if you discard it,len(and possiblyptr) 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
capsnapped 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
- Array Internals — the fixed-length backing storage a slice describes
- Map Internals — the other core built-in collection
- String Internals — a two-word header;
[]byte(s)andstring(b)conversions - Slice Aliasing and append Surprises — the gotcha, expanded
- copy Semantics Surprises —
copybounded byminof lengths - Zero Values and Memory Initialization — the nil slice as a zero value
- Value Semantics and Pointer Semantics — why the header copies but the array is shared
- Escape Analysis — whether a slice’s backing array lands on stack or heap
- Go Memory Allocator / Size Classes and Span Management —
growslice’s size-class rounding - Word Size and Architecture Portability — 24-byte vs 12-byte headers
- Go Internals MOC — parent map of content