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.
2 · Try it — len, cap, and the view
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).
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)
}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.
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:
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.)
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
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.
// 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
}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 | []int ≈ ArrayList<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 ifappendreallocated — 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
nilslices wrong. Anilslice is fine to range over, fine toappendto. It's not fine to index —(*nil)[0]panics. Thelen(nil) == 0check is your friend. - Holding onto a slice that pins a big backing array.
small := bigSlice[0:5]keepsbigSlice's entire backing array alive (GC can't free it). Usecopyto detach if the original is large. - The
:=shadowing within a loop.for _, s := range slices { ... }—sis 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)
- Print the header. Write a function
describe(s []int)that printslen,cap, and the pointer (usefmt.Printf("%p", s)). Call it after various slicings and appends. Watch the pointer change whenappendreallocates. - The capacity puzzle. What's
capafter these?s := make([]int, 0, 10); s = append(s, 1, 2, 3)— predict, then run. Then:t := s[1:2]— what'scap(t)? Why? - Implement
remove(s, i). Two versions: order-preserving (slow) and order-not-preserving (fast). Test on[1, 2, 3, 4, 5]removing index 2. - 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) usingslices.Clonefrom 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.