Slices, Internals & Idioms
Slices are Go's general-purpose sequence. They look simple (a dynamic
array with append and slicing syntax), but a few quirks
around sharing underlying memory cause the most common bugs
in beginner Go code. This chapter explains the internals once, so you
can confidently reason about every append.
Learning objectives
- Explain the three fields of a slice header: pointer, length, capacity.
- Create slices with literals,
make, and slicing. - Use
appendandcopycorrectly. - Recognize aliasing pitfalls when multiple slices share storage.
- Distinguish
nilfrom an empty slice (and know when it matters).
What a slice is
Under the hood, a slice is a 3-word struct that points to a piece of an underlying array:
ptr, len, cap.The slice header
- ptr: address of the first element the slice sees.
- len: number of elements the slice currently has;
len(s). - cap: elements in the underlying array from
ptrto its end;cap(s).
When append exceeds cap, Go allocates a
bigger array, copies the elements over, and returns a slice pointing
to the new one. Otherwise it just bumps len in place.
Creating slices
// Literal
s := []int{1, 2, 3}
// With length (and cap)
t := make([]int, 3) // len=3, cap=3
u := make([]int, 3, 10) // len=3, cap=10, grow cheaply up to 10
// Zero-valued
var v []int // nil slice, len=cap=0
Slicing syntax
The form is s[low:high:max] (the last one is rare;
it caps the new slice's capacity):
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // len=3, cap=4, [20 30 40]
t := arr[:2] // [10 20]
u := arr[3:] // [40 50]
full := arr[:] // view of the whole array
bounded := arr[1:3:3] // len=2, cap=2: capped
append
s := []int{1, 2}
s = append(s, 3) // [1 2 3]
s = append(s, 4, 5, 6) // [1 2 3 4 5 6]
other := []int{7, 8}
s = append(s, other...) // spread a slice into append
Golden rule: always reassign the result of append.
If the append triggered a reallocation, the old variable still points
to the old backing array.
append grows the slice, Go roughly doubles the capacity for small slices. But the exact pattern is an implementation detail, never rely on a specific growth factor. Use make([]T, 0, N) when you know N up front.
copy
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src) // n == 3 (the min of len(src), len(dst))
fmt.Println(dst, n) // [1 2 3] 3
copy only copies the overlap, it never grows the destination. It's the right tool for decoupling two slices so changes to one don't affect the other.
Aliasing pitfalls
The most common slice bug: two slices share the same backing array, and modifying one mutates the other:
a := []int{1, 2, 3, 4, 5}
b := a[1:4] // shares backing array
b[0] = 99
fmt.Println(a) // [1 99 3 4 5] , surprised?
Even scarier, append can sometimes visibly mutate another slice, sometimes not, depending on capacity:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // len 3, cap 5
b = append(b, 99) // fits in cap, mutates a!
fmt.Println(a) // [1 2 3 99 5]
Defensive copy when you need independence:
b := make([]int, len(a))
copy(b, a)
// ...or:
b := append([]int(nil), a...) // idiomatic one-liner
nil vs empty
var a []int // nil slice
b := []int{} // empty but NOT nil slice
fmt.Println(len(a), len(b)) // 0 0
fmt.Println(a == nil, b == nil) // true false
// Both can be append()ed to safely:
a = append(a, 1) // fine
In JSON encoding, a nil slice marshals to null
while an empty slice marshals to []. For public APIs,
consider which you want. Elsewhere they behave identically.
Multi-dimensional (slices of slices)
grid := [][]int{
{1, 2, 3},
{4, 5, 6},
}
fmt.Println(grid[1][2]) // 6
Inner slices can have different lengths (a "jagged" slice). For a true
rectangular matrix, prefer []int with manual indexing
(grid[i*width+j]) or a library like
gonum.org/v1/gonum/mat.
Common idioms
Remove element at index i (preserving order)
s = append(s[:i], s[i+1:]...)
Insert at index i
s = append(s[:i], append([]int{x}, s[i:]...)...)
Filter in place (allocation-free)
kept := s[:0]
for _, v := range s {
if keep(v) {
kept = append(kept, v)
}
}
s = kept
Check your understanding
Practice exercises
Unique elements
Write unique(xs []int) []int that returns a new slice with duplicates removed, preserving order of first occurrence. Use a map[int]bool to track seen elements.
Show solution
func unique(xs []int) []int {
seen := make(map[int]bool, len(xs))
out := make([]int, 0, len(xs))
for _, v := range xs {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
Rotate left by k
Write rotateLeft(xs []int, k int) []int that returns a new slice rotated left by k positions. rotateLeft([1,2,3,4,5], 2) → [3,4,5,1,2]. Handle k > len(xs).
Show solution
func rotateLeft(xs []int, k int) []int {
if len(xs) == 0 {
return xs
}
k = k % len(xs)
return append(append([]int{}, xs[k:]...), xs[:k]...)
}
We use append([]int{}, ...) to force a fresh backing array so the result doesn't alias xs.
Further reading
- Go slices: usage and internals: the definitive post
- Arrays, slices (and strings): The mechanics of 'append'
slicespackage: Go 1.21+ utilities (Contains, Sort, Min, Max, etc.)
Slices mastered, the #1 type in Go.