Skip to content
gohacks.dev
Basics

Full Slice Expressions and Capacity Control in Go Slices

The three index form s[low:high:max] caps a slice's capacity so append cannot overwrite your data. See the arithmetic, the panics and when it is worth it.

6 min read
Summarize in AI
Flat illustration of an editor window full of code.

Go's slice syntax has a third index that most people never use until the day they need it. It does one thing: it lets you limit how much of the underlying array a slice is allowed to grow into. That sounds like a small feature. It is the cleanest tool for the aliasing bug in the append trap, and it is what you reach for when you hand out part of a slice. If you have not yet met the header model, slices explained comes first, and the whole sequence is in the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The arithmetic

For a[low:high] the new slice has length high - low and capacity cap(a) - low. That capacity runs to the end of the array, however short the slice looks. The third index, max, replaces the end:

ExpressionLengthCapacity
a[2:5]3cap(a) - 2
a[2:5:5]33
a[2:5:8]36
a[:0:0]00

The constraint is 0 <= low <= high <= max <= cap(a). Break it and the program panics at run time, or the compiler rejects constant indexes that are plainly wrong. Here is the table as code, along with the effect on append:

main.goGo
package main

import "fmt"

func main() {
	a := make([]int, 10)
	for i := range a {
		a[i] = i
	}

	two := a[2:5]
	three := a[2:5:5]
	partial := a[2:5:8]
	fmt.Println(len(two), cap(two))
	fmt.Println(len(three), cap(three))
	fmt.Println(len(partial), cap(partial))

	two = append(two, -1)
	fmt.Println(a)

	three = append(three, -2)
	fmt.Println(a, three)

	partial = append(partial, -3, -4, -5)
	fmt.Println(a, partial)
}
Text
3 8
3 3
3 6
[0 1 2 3 4 -1 6 7 8 9]
[0 1 2 3 4 -1 6 7 8 9] [2 3 4 -2]
[0 1 2 3 4 -3 -4 -5 8 9] [2 3 4 -3 -4 -5]

The ordinary two index slice two has capacity 8, so appending to it wrote -1 over a[5], and the first print of a shows the damage. three has capacity 3, so appending forced a new array and a was unchanged. partial has room for three more elements, so its three appends fit inside the original array and wrote -3, -4, -5 into a[5], a[6] and a[7], and stopped there, short of a[8].

The common spelling

The form you will actually write is s[:len(s):len(s)], or more generally s[i:j:j]. It reads as "this window, and nothing beyond it". The capacity is exactly the length, so any append allocates and copies, and the original stays out of reach. If a method returns an internal slice, or splits a buffer into pieces that go to different goroutines, this is how you make each piece safe from the others:

main.goGo
package main

import "fmt"

func split(buf []byte, size int) [][]byte {
	var parts [][]byte
	for len(buf) > 0 {
		n := min(size, len(buf))
		parts = append(parts, buf[:n:n])
		buf = buf[n:]
	}
	return parts
}

func main() {
	data := []byte("abcdefghij")
	parts := split(data, 4)

	parts[0] = append(parts[0], '!', '!')
	fmt.Printf("%s %s\n", parts[0], parts[1])
	fmt.Println(string(data))
}
Text
abcd!! efgh
abcdefghij

Every chunk is a window into data with no spare capacity. Appending to the first part allocated a new array, so data still reads abcdefghij and the second part is intact. Without the third index, the same append would have overwritten efgh with the exclamation marks. The pattern keeps one allocation for the whole buffer, instead of a copy per chunk, and still makes the chunks safe to extend.

The clone idiom that falls out of it

A zero capacity slice of any existing slice, s[:0:0], is empty and has nowhere to append into. That makes it the starting point of the shortest clone that was available before slices.Clone existed:

main.goGo
package main

import "fmt"

func main() {
	src := []int{1, 2, 3}
	clone := append(src[:0:0], src...)

	src[0] = 99
	fmt.Println(src, clone)

	var nothing []int
	empty := []int{}
	fmt.Println(append(nothing[:0:0], nothing...) == nil, append(empty[:0:0], empty...) == nil)
}
Text
[99 2 3] [1 2 3]
true false

Because the capacity is zero, append has to allocate, and the result owns its array. Unlike append([]int(nil), src...), this form keeps a nil input nil and an empty input non-nil, so it behaves like slices.Clone in the last line. You will see it in code written between Go 1.2 and Go 1.21, and in the standard library's own source. New code should call slices.Clone and let the name carry the intent, as copying a slice the safe way recommends.

What goes wrong

The index checks are strict and the messages are specific. Asking for a capacity bigger than the array allows panics:

main.goGo
package main

import "fmt"

func main() {
	a := make([]int, 5)
	b := a[1:3:10]
	fmt.Println(b)
}
Text
panic: runtime error: slice bounds out of range [::10] with capacity 5

goroutine 1 [running]:
main.main()
	example.test/lab/main.go:7 +0x38
exit status 2

The runtime message reads slice bounds out of range [::10] with capacity 5. The part in brackets tells you which index failed, and here the empty first two positions mean the third one did, so you can find the offending expression without a debugger. It is the same family as slice bounds out of range panics. Using indexes in the wrong order, a[3:2:5], panics too. When the indexes are constants the compiler rejects the bad ones, as it does for ordinary slicing. When they are computed from data, treat them like any index and validate them before slicing.

Strings only accept two indexes. Three index slicing exists for arrays, pointers to arrays and slices, and a string's bytes are immutable so there is no capacity to limit.

A cap is not a copy

Capping stops append from writing into your array. It does not stop writes to elements the slice already covers: parts[0][0] = 'X' still changes data. If the receiver must not modify anything, give it a clone with slices.Clone, or a read-only interface. And a capped slice still pins the entire array for the garbage collector, so a small window on a huge buffer keeps all of it alive, which is the leak removing an element without leaking it describes from the other side.

Where it is not worth the syntax

If you immediately copy the data, you do not need to cap it first. If your function owns a slice and returns it as its only owner, the receiver is free to append and nobody else is affected. And in code that never appends, such as a read-only parser, the extra index is noise. I reach for the form in three places: functions that return a sub-slice of their own state, code that splits a buffer and hands pieces out, and fixing the path-building bug shown in the append trap article. The specification describes it in the section on full slice expressions, and the Go team added it in Go 1.2 for exactly these reasons, as the Go 1.2 release notes explain.

A last practical note on reading code that uses the form. When you see x[a:b:b] in a review, the author is making a promise about ownership: nothing the callee appends can reach the array past b. If the surrounding function then stores x somewhere long-lived, check that the promise covers the elements inside the window as well, because the cap does not stop element writes. That is the question I ask every time, and it usually separates a deliberate use from a cargo-culted one.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.