19. How slices really work: length, capacity and `append`
A slice is not a list object. It's a tiny three-field value — a pointer to an array, a length, and a capacity — and once you can picture those three fields, every surprising thing slices do becomes obvious.
The three fields
slice header backing array
┌──────────────┐ ┌───┬───┬───┬───┬───┐
│ ptr ────────┼────────► │ 4 │ 8 │15 │ │ │
│ len 3 │ └───┴───┴───┴───┴───┘
│ cap 5 │ ◄─ len=3 ─►
└──────────────┘ ◄──── cap=5 ─────►
- len — how many elements you can see and index.
- cap — how many slots exist in the backing array from the slice's start.
- ptr — where in that array the slice begins.
len() and cap() show you two of them:
package main
import "fmt"
func main() {
s := make([]int, 3, 5)
fmt.Println(s, "len:", len(s), "cap:", cap(s))
s = append(s, 99)
fmt.Println(s, "len:", len(s), "cap:", cap(s))
}
The append fit in the spare capacity, so no new array was needed — length went up, capacity stayed put.
What append does when there's no room
Watch the capacity as a slice grows from nothing:
package main
import "fmt"
func main() {
var s []int
fmt.Printf("start len=%d cap=%d\n", len(s), cap(s))
for i := 1; i <= 9; i++ {
s = append(s, i)
fmt.Printf("append %d -> len=%d cap=%d\n", i, len(s), cap(s))
}
}
Capacity jumps 0 → 1 → 2 → 4 → 8 → 16. When append finds no spare slot it:
- allocates a new, bigger backing array (roughly double for small slices),
- copies every existing element across,
- adds the new element,
- returns a slice header pointing at the new array.
That's why you must write s = append(s, x): the returned header may point
somewhere completely different from the one you passed in.
The doubling is what makes appending in a loop cheap overall. Each individual grow costs a copy, but they get rarer as the slice gets bigger — a million appends do far fewer than a million copies.
Why pre-sizing matters
If you know the final size, make with a capacity and skip the regrowth
entirely:
package main
import "fmt"
func main() {
grown := []int{}
regrows := 0
last := cap(grown)
for i := 0; i < 1000; i++ {
grown = append(grown, i)
if cap(grown) != last {
regrows++
last = cap(grown)
}
}
sized := make([]int, 0, 1000)
sizedRegrows := 0
last = cap(sized)
for i := 0; i < 1000; i++ {
sized = append(sized, i)
if cap(sized) != last {
sizedRegrows++
last = cap(sized)
}
}
fmt.Println("unsized regrows:", regrows)
fmt.Println("pre-sized regrows:", sizedRegrows)
}
Twelve allocate-and-copy rounds versus zero. On a hot path with big slices,
that's real time and real garbage. make([]T, 0, n) is one of the cheapest
performance wins in Go — and it costs you one extra argument.
Length vs capacity, in practice
package main
import "fmt"
func main() {
s := make([]int, 0, 4)
fmt.Println("len:", len(s), "cap:", cap(s))
// fmt.Println(s[0]) // panics: index out of range
s = append(s, 7)
fmt.Println("now readable:", s[0])
}
Capacity is not permission to index. Only len elements exist as far as you
are concerned; the rest of the backing array is reserved space. Indexing past
len panics even when cap is bigger.
copy — moving elements between slices
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println("copied", n, "elements:", dst)
full := make([]int, len(src))
copy(full, src)
full[0] = 100
fmt.Println("src: ", src)
fmt.Println("full:", full)
}
copy moves min(len(dst), len(src)) elements and returns how many. It
never grows anything — it fills what's already there. The second half is the
idiom for a genuine independent copy of a slice: make one the same
length, copy into it. You'll need it in the next lesson, where sharing
turns into a bug.
Truncating without reallocating
Because a slice header is just three numbers, you can cheaply shrink one:
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
fmt.Println(s, "len:", len(s), "cap:", cap(s))
s = s[:0]
fmt.Println(s, "len:", len(s), "cap:", cap(s))
s = append(s, 42)
fmt.Println(s, "len:", len(s), "cap:", cap(s))
}
s[:0] sets the length to zero while keeping the same backing array — the
capacity is untouched. Reusing a buffer this way (buf = buf[:0] at the top
of each loop iteration) is a standard Go trick for avoiding allocations.
Your turn
Build a slice of the first 8 Fibonacci numbers using make with a capacity
of 8, and print the slice plus its capacity so the output is exactly:
[1 1 2 3 5 8 13 21] cap=8
package main
import "fmt"
func main() {
fib := make([]int, 0, 8)
// append the first 8 Fibonacci numbers (starting 1, 1)
fmt.Printf("%v cap=%d\n", fib, cap(fib))
}
package main
import "fmt"
func main() {
fib := make([]int, 0, 8)
a, b := 1, 1
for i := 0; i < 8; i++ {
fib = append(fib, a)
a, b = b, a+b
}
fmt.Printf("%v cap=%d\n", fib, cap(fib))
}
Next: the sharp edge. Two slices pointing at the same array.