Introduction to Slices in Golang
Published Updated Golang 13 min read
Length against capacity, why append sometimes mutates the array you sliced from, the three-index expression that prevents it, and the small slice that keeps a large array alive.
A slice is a three-word header: a pointer to an array, a length and a capacity. Almost every surprising thing a slice does follows from that header being copied while the array it points at is not.
Written against Go 1.22.
Declaring and creating
var a []int // nil slice: len 0, cap 0, no array
b := []int{} // empty, non-nil
c := []int{1, 2, 3} // literal, len 3, cap 3
d := make([]int, 5) // len 5, cap 5, all zero
e := make([]int, 0, 10) // len 0, cap 10 — room for ten appends
f := [5]int{1, 2, 3, 4, 5}[1:3] // from an array
The make([]T, 0, n) form is the one to reach for when you know roughly how many elements are
coming. It avoids reallocation on the way to n.
A nil slice is usable. Appending to it works, len is 0, and ranging over it does nothing:
var tags []string
tags = append(tags, "go") // fine — no initialisation needed
The one place nil and empty differ is encoding:
var nilSlice []int
emptySlice := []int{}
json.Marshal(nilSlice) // null
json.Marshal(emptySlice) // []
If an API contract says “always an array”, initialise with []int{}. Otherwise prefer nil. It is
the idiomatic zero value and needs no allocation.
Length and capacity
s := make([]int, 3, 8)
fmt.Println(len(s), cap(s)) // 3 8
len is how many elements you can index. cap is how many the backing array can hold from the
slice’s start before it must be reallocated. Indexing past len panics even when capacity remains:
s := make([]int, 3, 8)
_ = s[5] // panic: index out of range [5] with length 3
s = s[:6] // re-slice within capacity: now len 6, no allocation
_ = s[5] // fine, value 0
Slicing an existing slice can reach into its capacity:
a := []int{1, 2, 3, 4, 5}
b := a[1:3]
fmt.Println(b, len(b), cap(b)) // [2 3] 2 4
cap(b) is 4 because b starts at index 1 of a five-element array, so four slots remain to its
right. That number is what decides the next section.
The append trap
This is the one to understand properly, because the compiler will not warn you and the symptom appears in unrelated code.
a := []int{1, 2, 3, 4, 5}
b := a[:2] // len 2, cap 5 — same array as a
b = append(b, 99)
fmt.Println(b) // [1 2 99]
fmt.Println(a) // [1 2 99 4 5] <- a changed
append had spare capacity, so it wrote 99 into the existing array at index 2, which a also
sees. No copy was made because none was needed.
Now the same code where capacity happens to be exhausted:
a := []int{1, 2}
b := a[:2] // len 2, cap 2
b = append(b, 99) // no room: new array allocated, contents copied
fmt.Println(b) // [1 2 99]
fmt.Println(a) // [1 2] <- a unchanged
So whether append mutates the original depends on capacity, which usually depends on data. That
is the worst possible failure mode: correct in a test with three elements, wrong in production with
three hundred.
The fix is the three-index slice expression, which caps the capacity explicitly:
a := []int{1, 2, 3, 4, 5}
b := a[0:2:2] // low:high:max — len 2, cap 2
b = append(b, 99) // cap exhausted, so a copy is forced
fmt.Println(a) // [1 2 3 4 5] <- safe
a[low:high:max] sets capacity to max - low. When you hand a sub-slice to code that might append
to it, cap it. Or copy, see below.
Never rely on growth policy. Go roughly doubles small slices and grows large ones more
conservatively, but the exact factor is an implementation detail that has changed between releases.
Code whose correctness depends on cap after an append is broken code.
copy
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src) // n == 3, dst is independent
copy returns the number of elements copied, which is min(len(dst), len(src)), so a dst that is
too short silently copies a prefix. Size it from len(src), and if you want a clone:
clone := slices.Clone(src) // Go 1.21+, one line, correct length
copy also works from a string into []byte, and handles overlapping slices correctly, which is why
it is safe for shifting elements within one slice.
Removing an element
// order does not matter: swap with the last and truncate — O(1)
s[i] = s[len(s)-1]
s = s[:len(s)-1]
// order matters: shift the tail left — O(n)
s = append(s[:i], s[i+1:]...)
// Go 1.21+, clearer and handles the tail correctly
s = slices.Delete(s, i, i+1)
The append(s[:i], s[i+1:]...) idiom has a subtlety worth knowing when the elements are pointers or
contain them, after shifting, the final slot still holds the last element’s old value, and because
it is inside the capacity it stays reachable. That keeps an object alive that you believe you
removed. slices.Delete zeroes the vacated tail; if you are writing it by hand, do the same:
copy(s[i:], s[i+1:])
s[len(s)-1] = nil // release the reference
s = s[:len(s)-1]
A small slice can keep a large array alive
func firstLine(data []byte) []byte {
i := bytes.IndexByte(data, '\n')
return data[:i] // shares the whole array
}
Return a 40-byte slice of a 10 MB buffer and the garbage collector cannot free the 10 MB, because the slice header still points into it. Nothing about the returned value suggests this.
When a small result outlives a large source, copy:
func firstLine(data []byte) []byte {
i := bytes.IndexByte(data, '\n')
return bytes.Clone(data[:i]) // independent, releases the buffer
}
Slices are passed by value, and that is confusing
The header is copied; the array is not. So a function can modify existing elements of the caller’s slice, and cannot change its length:
func setFirst(s []int) {
s[0] = 99 // caller sees this
}
func addOne(s []int) {
s = append(s, 1) // caller does NOT see this
}
func addOneProperly(s []int) []int {
return append(s, 1) // return it
}
append may reallocate and in any case updates the local copy of the header, so the caller’s len
is unchanged. This is why every function that grows a slice returns it, and why s = append(s, x)
is written with the assignment every time.
Two dimensions
grid := make([][]int, rows)
for i := range grid {
grid[i] = make([]int, cols) // each row separately
}
There is no make([][]int, rows, cols). For a fixed-size rectangle, one backing array with index
arithmetic is faster and has better locality:
flat := make([]int, rows*cols)
at := func(r, c int) int { return flat[r*cols+c] }
The slices package
Go 1.21 added generic helpers that replace a lot of hand-written loops:
slices.Contains(s, 3)
slices.Index(s, 3)
slices.Sort(s)
slices.SortFunc(people, func(a, b Person) int { return cmp.Compare(a.Age, b.Age) })
slices.Reverse(s)
slices.Equal(a, b)
slices.Max(s)
slices.Clone(s)
slices.Delete(s, 1, 3)
slices.BinarySearch(sorted, 3)
SortFunc takes a three-way comparison returning negative, zero or positive, cmp.Compare is the
usual way to produce one.
Frequently asked questions
What is the difference between length and capacity?
len is the number of elements you can
index; cap is how many the backing array can hold from the slice’s start. Re-slicing up to cap
allocates nothing.
Why did appending to one slice change another?
They share a backing array and append had spare
capacity, so it wrote in place. Use the three-index form a[low:high:max] to cap capacity, or copy.
When does append allocate a new array?
Only when capacity is exhausted. Whether that happens depends on the data, which is why an aliasing bug can pass every small test.
What is a[1:3:3] for?
The third index sets capacity. It forces the next append to allocate
rather than write into the original array.
Is a nil slice usable?
Yes, len is 0, ranging does nothing, and append works. The only
practical difference from an empty slice is that it marshals to null rather than [].
Why does my function’s append not affect the caller?
The slice header is passed by value, so
append updates the local copy. Return the slice and assign it.
How do I copy a slice properly?
slices.Clone(src), or make at len(src) then copy. copy
transfers only min(len(dst), len(src)) elements, so an undersized destination silently truncates.
Why is memory not being freed after I sliced a small piece out?
The slice header still points into the original array, keeping all of it reachable. Clone the small piece so the large buffer can be collected.
How do I remove an element?
slices.Delete(s, i, i+1). By hand, shift with copy, zero the
vacated tail if elements hold references, then truncate.
Can I rely on how much append grows a slice?
No. The growth factor is an implementation detail and has changed between Go releases.