19. How slices really work: length, capacity and `append`

📖 Reading · 10 min
💡 Every code box below is live — edit it and hit Run.

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:

  1. allocates a new, bigger backing array (roughly double for small slices),
  2. copies every existing element across,
  3. adds the new element,
  4. 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.