04 / 20 · Day 2
Day 2 · Concept 04

Slices

The Go type you'll use more than any other. A slice is a view — three words of memory: a pointer, a length, a capacity. It looks like an array, behaves mostly like one, but the difference becomes critical the moment you call append or pass a slice into a function. Once you see the slice header in your mind's eye, most Go gotchas disappear.


1 · The intuition

A slice in Go is not an array — it's a tiny struct of three fields: (ptr, len, cap). The ptr points into an underlying array. The len is how many elements you can index. The cap is how far the view can stretch before Go has to allocate a new backing array.

When you pass a slice to a function, you're passing those three words — not the underlying array. That's why mutating s[i] in a callee is visible to the caller (same array), but append-ing in a callee might or might not be visible (might be a new array). This single distinction is the source of most Go-newcomer bugs.

The slice mantra. Pointer. Length. Capacity. Say it three times. Every slice question reduces to one of those three.

2 · Try it — len, cap, and the view

go main.go · slice header in action
package main

import "fmt"

func main() {
    // Make a slice from a literal. Cap defaults to len.
    s := []int{10, 20, 30}
    fmt.Printf("s=%v  len=%d  cap=%d
", s, len(s), cap(s))

    // Build with make(type, len, cap). Pre-allocate cap to avoid re-allocs.
    t := make([]int, 3, 8)
    fmt.Printf("t=%v  len=%d  cap=%d
", t, len(t), cap(t))

    // Slice an array — len shrinks to the new slice, cap is the rest.
    arr := [6]int{1, 2, 3, 4, 5, 6}
    u := arr[1:4]
    fmt.Printf("u=%v  len=%d  cap=%d
", u, len(u), cap(u))
}

Three constructions, three different cap values. u's cap is 5 because the underlying array has 6 elements and u starts at index 1 — there are 5 elements from index 1 to the end of the array.

3 · The append that surprised you

append grows a slice. If there's spare capacity, it just bumps len and writes. If not, it allocates a new array (typically 2× the old cap), copies the existing elements, and returns a slice pointing into the new array. That's why you always reassign: s = append(s, x).

go main.go · the append surprise
package main

import "fmt"

func main() {
    s := make([]int, 0, 3)  // len=0, cap=3
    s = append(s, 1)
    s = append(s, 2)
    s = append(s, 3)
    fmt.Printf("after 3 appends: len=%d cap=%d  s=%v
", len(s), cap(s), s)

    // The 4th append exceeds cap=3 — Go allocates a new array
    s = append(s, 4)
    fmt.Printf("after 4 appends: len=%d cap=%d  s=%v
", len(s), cap(s), s)
}
The growth rule. Go's runtime doubles the capacity until the slice gets large, then grows more conservatively (~25% per realloc above ~256 elements). The growth strategy is in runtime/slice.go — open it once for a sanity check.

4 · The aliasing footgun

Two slices can share the same underlying array. Mutating one then "sees" through the other. This is occasionally useful and frequently a bug.

go main.go · slice aliasing
package main

import "fmt"

func main() {
    arr := [6]int{1, 2, 3, 4, 5, 6}

    a := arr[0:3]   // [1, 2, 3]
    b := arr[2:5]   // [3, 4, 5]

    fmt.Printf("a=%v  b=%v
", a, b)

    // Mutate through a — index 2 of the array is element 0 of b
    a[2] = 999
    fmt.Printf("after a[2]=999
  a=%v  b=%v  arr=%v
", a, b, arr)
}

a[2] and b[0] point at the same byte. Writing one is writing both. The fix when you need an independent copy: copy(dst, src) or a full slice expression like s[:3:3] that limits the cap.

5 · The slice-of-slices trap

One of Go's most-asked questions on Stack Overflow:

go main.go · the loop-with-append trap
package main

import "fmt"

func main() {
    base := make([]int, 3, 4) // len 3, cap 4 — one spare slot
    var all [][]int

    for i := 0; i < 3; i++ {
        s := append(base, i)
        all = append(all, s)
    }

    // Surprise — all three slices share the same backing array.
    for _, s := range all {
        fmt.Println(s)
    }
}

Each loop iteration: append(base, i) sees that base has spare capacity (len 3, cap 4), so instead of allocating it writes i into the shared backing array at index 3 and returns a length-4 view. All three slices point at that same array — and the final write wins. (Had base been full — cap == len — each append would reallocate and the slices would be independent.)

The fix. Force a fresh allocation by using a full slice expression that caps capacity at the current length: s := append(base[:len(base):len(base)], i). Or just copy: s := make([]int, len(base)+1); copy(s, base); s[len(base)] = i.

6 · The 12 slice patterns you'll use

go slice-tricks.go · the cookbook
s := []int{1, 2, 3, 4, 5}

// Make a copy (independent backing array)
dup := make([]int, len(s))
copy(dup, s)

// Remove element at index i (preserves order)
i := 2
s = append(s[:i], s[i+1:]...)

// Remove element at index i (faster, doesn't preserve order)
s[i] = s[len(s)-1]
s = s[:len(s)-1]

// Reverse in place
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
    s[i], s[j] = s[j], s[i]
}

// Filter in place (keep only positive numbers)
n := 0
for _, x := range s {
    if x > 0 {
        s[n] = x
        n++
    }
}
s = s[:n]

// Insert at index i
s = append(s[:i], append([]int{99}, s[i:]...)...)

// Pop front (queue)
front := s[0]
s = s[1:]
_ = front

// Pop back (stack)
back := s[len(s)-1]
s = s[:len(s)-1]
_ = back

// Empty (preserves capacity, useful for reuse)
s = s[:0]

// Chunk into groups of n
n2 := 2
var chunks [][]int
for i := 0; i < len(s); i += n2 {
    end := i + n2
    if end > len(s) { end = len(s) }
    chunks = append(chunks, s[i:end])
}

// Compare two slices (Go 1.21+)
// equal := slices.Equal(a, b)

These are the slice tricks. They show up in every Go codebase. The official slice-tricks wiki has more variants; memorise the eight above and you cover 95% of real use.

7 · From the wild — slices in production Go

A snippet from etcd's storage layer. Notice — append pattern, the cap re-use trick, the in-place filter.

go etcd · server/storage/mvcc/kvstore.go (paraphrased)
// filterUnique removes adjacent duplicates from a sorted slice in place.
func filterUnique(keys [][]byte) [][]byte {
    if len(keys) == 0 {
        return keys
    }
    n := 1
    for i := 1; i < len(keys); i++ {
        if !bytes.Equal(keys[i], keys[i-1]) {
            keys[n] = keys[i]
            n++
        }
    }
    return keys[:n]
}

// growBatch makes sure batch has capacity to hold n more entries.
// Pre-allocates to avoid append-realloc churn in hot paths.
func (b *batch) growBatch(n int) {
    if cap(b.entries)-len(b.entries) >= n {
        return
    }
    bigger := make([]entry, len(b.entries), len(b.entries)+n+64)
    copy(bigger, b.entries)
    b.entries = bigger
}
From the wild: github.com/etcd-io/etcd · Apache 2.0

8 · Coming from another language?

If you know…The mental model
Python []int is like list — both grow with append. But Python lists are reference types entirely; Go slices have the (ptr, len, cap) header you can reason about.
Java []intArrayList<Integer>, but you also get raw arrays underneath. The cap field is the equivalent of ArrayList's internal capacity — you just see it.
JavaScript JS arrays are dynamic and reference-typed. The difference: in Go you can take a sub-slice that shares storage. JS arr.slice(1, 3) always copies; Go arr[1:3] never copies.
C / C++ Closer than you'd think. A slice is essentially std::span<T> plus a capacity field. C++ std::vector is the closest single-type match, but with automatic copy-on-share semantics that Go avoids.
Rust &[T] is the read-only equivalent of a Go slice. Vec<T> owns its backing array — Go slices don't have ownership; the GC handles cleanup.

9 · Common mistakes

  • Forgetting to reassign append's return. append(s, x) alone is useless if append reallocated — the new slice is lost. Always: s = append(s, x).
  • Slicing without realising aliasing. copy() when you need an independent slice.
  • Passing a slice expecting "the slice"; getting the header only. Mutating s[i] works (same backing array). Appending may not propagate back to the caller. Return the new slice or use a pointer-to-slice.
  • Using nil slices wrong. A nil slice is fine to range over, fine to append to. It's not fine to index — (*nil)[0] panics. The len(nil) == 0 check is your friend.
  • Holding onto a slice that pins a big backing array. small := bigSlice[0:5] keeps bigSlice's entire backing array alive (GC can't free it). Use copy to detach if the original is large.
  • The := shadowing within a loop. for _, s := range slices { ... }s is reassigned each iteration in Go 1.22+; older versions reuse the same memory. Watch out if mixing modules with older Go modes.

10 · Exercises (~15 min)

  1. Print the header. Write a function describe(s []int) that prints len, cap, and the pointer (use fmt.Printf("%p", s)). Call it after various slicings and appends. Watch the pointer change when append reallocates.
  2. The capacity puzzle. What's cap after these? s := make([]int, 0, 10); s = append(s, 1, 2, 3) — predict, then run. Then: t := s[1:2] — what's cap(t)? Why?
  3. Implement remove(s, i). Two versions: order-preserving (slow) and order-not-preserving (fast). Test on [1, 2, 3, 4, 5] removing index 2.
  4. Reproduce the loop-trap. Run the code from section 5. Now fix it three ways: (a) using the three-index slice expression, (b) using make + copy, (c) using slices.Clone from Go 1.21. Time each on a slice of 1 million — surprises?

11 · When it clicks

  • You see a slice and the (ptr, len, cap) triple appears in your mind.
  • You reach for make([]T, 0, n) when you know the final size, by reflex.
  • You don't trust a slice argument until you've decided: does this function read or write?
  • You can spot the aliasing bug in pull requests without running the code.
Found this useful?