Skip to content
gohacks.dev
Basics

Slice Bounds Out of Range Panics in Go Explained Simply

Understand slice bounds out of range in Go: the four message forms, capacity versus length, strings.Index misuse, pagination bugs and a clamp helper.

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

Indexing panics when one position is wrong. Slicing panics when a pair of positions is wrong, and the rules are a little looser than most people remember, which makes the message harder to read than its sibling index out of range. This page lays out the rule, the four message forms, and the places where slicing bugs hide in real code.

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

It belongs to the error handling guide. Everything ran on Go 1.25.6.

The rule

A slice expression s[low:high] is valid when 0 <= low <= high <= cap(s) for slices, and 0 <= low <= high <= len(s) for strings and arrays. Both bounds are optional: s[:high] means low is 0, s[low:] means high is len(s). Anything outside that range panics at run time. The part that surprises people is that the upper limit for a slice is its capacity, not its length, so this is legal even though the result goes past len(s):

main.goGo
package main

import "fmt"

func main() {
	s := make([]int, 2, 5)
	s[0], s[1] = 1, 2

	t := s[:4] // beyond len(s)=2, within cap(s)=5
	fmt.Println(len(s), cap(s), t, len(t))
}
Text
2 5 [1 2 0 0] 4

The reslice brings the zeroed elements in the spare capacity into view, giving [1 2 0 0]. It is legal and rarely what you want, since it lets code read elements that were never appended. It is also why a slice bounds error says "capacity" for slices and "length" for strings.

The message forms

main.goGo
package main

import "fmt"

func try(name string, f func()) {
	defer func() { fmt.Printf("%-14s %v\n", name, recover()) }()
	f()
}

func main() {
	s := []int{1, 2, 3}
	str := "hello"
	hi, lo, neg := 5, 2, -1
	one := 1

	try("high > cap", func() { _ = s[:hi] })
	try("low > high", func() { _ = s[lo:one] })
	try("low > len", func() { _ = s[hi:] })
	try("string high", func() { _ = str[:hi+3] })
	try("negative", func() { _ = s[neg:] })
}
Text
high > cap     runtime error: slice bounds out of range [:5] with capacity 3
low > high     runtime error: slice bounds out of range [2:1]
low > len      runtime error: slice bounds out of range [5:3]
string high    runtime error: slice bounds out of range [:8] with length 5
negative       runtime error: slice bounds out of range [-1:]

Reading them: slice bounds out of range [:5] with capacity 3 says the high bound was 5 and the capacity 3. [2:1] says the low bound exceeded the high one, which is a swapped pair or a computed value that went wrong. [5:3] is what you get from s[5:] on a length-3 slice: the missing high bound defaults to the length, and the low bound is above it. For strings the same forms say with length. A negative bound reads [-1:]. The numbers always tell you which bound to look at.

Cause 1: paging past the end

Pagination is the textbook case: take page n of size k from a slice. The slice items[offset : offset+limit] panics when the offset is past the end or the page runs over:

main.goGo
package main

import "fmt"

func page(items []string, offset, limit int) (result []string) {
	defer func() {
		if r := recover(); r != nil {
			result = nil
			fmt.Println("panic:", r)
		}
	}()
	return items[offset : offset+limit]
}

func pageSafe(items []string, offset, limit int) []string {
	start := min(max(offset, 0), len(items))
	end := min(start+max(limit, 0), len(items))
	return items[start:end]
}

func main() {
	items := []string{"a", "b", "c", "d", "e"}
	fmt.Println(page(items, 3, 4))
	fmt.Println(pageSafe(items, 3, 4))
	fmt.Println(pageSafe(items, 99, 4), pageSafe(items, -5, 2))
}
Text
panic: runtime error: slice bounds out of range [:7] with capacity 5
[]
[d e]
[] [a b]

The unsafe version panics at the last page, because 3+4 is 7 and the length is 5. The safe version clamps both ends with min and max, which became built-ins in Go 1.21 (min and max), and returns a short or empty page for any input. Page numbers and limits arrive from query strings, so they are untrusted: a request for page 999999 should return an empty list, not crash a handler.

Cause 2: strings.Index returning -1

strings.Index returns -1 when the substring is absent, and the code that uses it as an offset slices from -1 or 0:

main.goGo
package main

import (
	"fmt"
	"strings"
)

func after(s, sep string) string {
	i := strings.Index(s, sep)
	return s[i+len(sep):]
}

func main() {
	fmt.Println(after("key=value", "="))
	fmt.Println(after("novalue", "="))
}
Text
value
novalue

With no =, i is -1 and the expression becomes s[0:] by luck of the arithmetic (-1 + 1), so the function quietly returns the whole input as the value. With a longer separator the start would be negative and the call would panic. Either way the logic is wrong, and the silent version is the worse one. The standard library has the right tools: strings.Cut splits on the first separator and reports whether it was found, and strings.CutPrefix and CutSuffix remove a prefix or suffix safely.

main.goGo
package main

import (
	"fmt"
	"strings"
)

func main() {
	k, v, ok := strings.Cut("key=value", "=")
	fmt.Println(k, v, ok)

	k, v, ok = strings.Cut("novalue", "=")
	fmt.Printf("%q %q %v\n", k, v, ok)

	rest, found := strings.CutPrefix("Bearer abc123", "Bearer ")
	fmt.Println(rest, found)
}
Text
key value true
"novalue" "" false
abc123 true

Prefer Cut over Index plus slicing whenever you split on a delimiter. It cannot go out of range, and the ok result makes the missing case explicit.

Cause 3: swapped or computed bounds

[2:1] appears when a calculation produces a high bound below the low. A window of the last n items is s[len(s)-n:], which is negative when n exceeds the length. A midpoint search that updates the wrong end produces [lo:hi] with lo > hi after a bug. Print the bounds before the slice expression in the failing case, or write a test with a table of edge cases, empty input, one element, and n equal to len, len+1 and 0. The panic message gives you the values, so the arithmetic usually shows itself.

Cause 4: slicing a copy that shrank

The same stale-length bug as in indexing applies: computing n := len(s) once and then slicing s[:n] after s has been truncated panics. Re-read len(s) where you use it, or restructure so the slice is not modified between computing and using the bound (removing from a slice).

Why this matters more than it looks

Slicing is how Go code parses: headers, paths, tokens, frames in a protocol. A parser written with hand-computed offsets is a long chain of expressions like buf[n:n+4], and each is a place where truncated or hostile input produces a panic. In a server that handles untrusted bytes, a slice bounds panic is a denial of service: one malformed request ends a goroutine, and an unrecovered one ends the process. The defensive pattern is to check the remaining length before every read (if len(buf) < n+4 { return errShort }), or to use a reader type that returns errors, such as bytes.Reader and encoding/binary's Read, so the bounds logic lives in tested library code. The Go fuzzer is the right tool to find the offsets you missed: a fuzz test feeds a parser random inputs, and a slice bounds panic is exactly the failure it reports.

Defensive helpers

A small generic helper makes the safe form the easy form wherever you slice with outside input:

main.goGo
package main

import "fmt"

// Window returns s[from:to] with both bounds clamped to the valid range.
func Window[T any](s []T, from, to int) []T {
	from = min(max(from, 0), len(s))
	to = min(max(to, from), len(s))
	return s[from:to]
}

func main() {
	s := []int{1, 2, 3, 4, 5}
	fmt.Println(Window(s, 1, 3), Window(s, 3, 100), Window(s, -2, 2), Window(s, 4, 1))
}
Text
[2 3] [4 5] [1 2] []

Four bad inputs, and no panic. The behavior for impossible requests, an empty slice, is the policy decision to make explicit: sometimes an error is more honest than an empty result, especially in an API where the caller can fix the request.

Three-index slices and aliasing

One related feature is the full slice expression s[low:high:max], which also caps the capacity of the result. It panics under the same family of rules (0 <= low <= high <= max <= cap(s)), and its use is to stop a later append from overwriting the original array (three-index slices). The messages for it read slice bounds out of range [::5] with capacity 3, with the same meaning applied to the third bound.

Checklist

  1. Read the message to see which bound failed.
  2. Find where the bound came from: user input, Index, a page number, a length that changed.
  3. Replace Index and slicing with Cut or CutPrefix.
  4. Clamp offsets with min and max for anything external.
  5. Test the empty, one-element and exact-length cases.

The language definition is in the specification's section on slice expressions, and the helper functions are in the strings package.

Also inTooling
Taggederror fix
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.