Skip to content
gohacks.dev
Basics

Index Out of Range Panics in Go and How to Avoid Them

Fix panic index out of range in Go: read the message, find off-by-one loops, empty slices, os.Args and Split results, and guard accesses safely.

7 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

The message is one of the clearest in Go: panic: runtime error: index out of range [5] with length 3. It tells you the index that was used and the length of the thing indexed. Most of the time you can read the cause from those two numbers. This page covers how to read the variants, the handful of ways the bug is usually written, and how to write the guard that makes it go away.

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

It belongs to the error handling guide and has a close relative in slice bounds out of range. Everything ran on Go 1.25.6.

Reading the message

main.goGo
package main

import "fmt"

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

func main() {
	s := []int{10, 20, 30}
	i := 5
	neg := -1
	var empty []int
	str := "go"

	try("too big", func() { _ = s[i] })
	try("at length", func() { _ = s[len(s)] })
	try("negative", func() { _ = s[neg] })
	try("empty", func() { _ = empty[0] })
	try("string", func() { _ = str[i] })
}
Text
too big      runtime error: index out of range [5] with length 3
at length    runtime error: index out of range [3] with length 3
negative     runtime error: index out of range [-1]
empty        runtime error: index out of range [0] with length 0
string       runtime error: index out of range [5] with length 2

The first number is the index you used, the second is the length. Indexing at exactly len(s) is the classic off-by-one: valid indexes stop at len-1. A negative index reads index out of range [-1] with no length, since no length could make it valid. The empty slice shows [0] with length 0, and a string behaves like a slice of bytes. In every case the fix lies in why the program thought the position existed.

Cause 1: off by one in a loop

The most common source is a loop condition one step too generous:

main.goGo
package main

import "fmt"

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()
	items := []string{"a", "b", "c"}
	for i := 0; i <= len(items); i++ { // <= visits index 3
		fmt.Println(i, items[i])
	}
}
Text
0 a
1 b
2 c
recovered: runtime error: index out of range [3] with length 3

The loop prints three lines and then panics with index out of range [3] with length 3. The cure is <, or better, no index at all: for i, item := range items cannot go out of range, and for range n handles the counting loops. Use explicit indexes only when you need the arithmetic, and then check the boundaries at the edges of the loop, where the first and last passes use i-1 or i+1:

Go
for i := 1; i < len(xs); i++ { // starts at 1 so xs[i-1] exists
	diff := xs[i] - xs[i-1]
	_ = diff
}

Cause 2: the slice is empty

Code that assumes at least one element, first := items[0], panics when there are none. Empty slices come from places that look reliable: a query with no rows, a filter that matched nothing, a split of an empty string. The guard is one line, and it has to be there:

main.goGo
package main

import (
	"fmt"
	"strings"
)

func first(items []string) (string, bool) {
	if len(items) == 0 {
		return "", false
	}
	return items[0], true
}

func main() {
	fmt.Println(first(nil))
	fmt.Println(first([]string{"x"}))

	fmt.Println(len(strings.Split("", ",")), len(strings.Fields("")))
}
Text
 false
x true
1 0

The last line is a surprise worth remembering: strings.Split("", ",") returns a slice with one empty string, so its length is 1, not 0, while strings.Fields("") returns an empty slice. Code that does parts := strings.Split(line, ","); name := parts[0] therefore never panics on empty input, and code that uses Fields does. Know which one you called.

Cause 3: more arguments or fields expected than present

Reading os.Args[1] with no arguments, parts[2] from a line with two fields, or matches[1] from a regular expression that did not match are all this bug, and all driven by input:

main.goGo
package main

import (
	"fmt"
	"regexp"
	"strings"
)

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()

	re := regexp.MustCompile(`id=(\d+)`)

	m := re.FindStringSubmatch("no match here")
	fmt.Println("matches:", len(m))

	parts := strings.Split("a,b", ",")
	fmt.Println("parts:", len(parts))

	fmt.Println(m[1]) // m is nil: there is no group 1
}
Text
matches: 0
parts: 2
recovered: runtime error: index out of range [1] with length 0

FindStringSubmatch returns nil when nothing matches, so m[1] panics with index out of range [1] with length 0. The fix is always to check the length before reading, with a message for the user in the cases where it is their input:

Go
if len(os.Args) < 2 {
	fmt.Fprintln(os.Stderr, "usage: tool <file>")
	os.Exit(64)
}

Treat anything that comes from outside the program, arguments, files, network, user input, as having an unknown shape, and check before indexing (command line arguments shows the usual patterns).

Cause 4: a stale index after removal

An index computed before the slice changed can point past the new end. Deleting while iterating is the usual case:

main.goGo
package main

import "fmt"

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()
	nums := []int{1, 2, 3, 4, 5, 6}

	n := len(nums)
	for i := 0; i < n; i++ { // n was computed once, before the removals
		if nums[i]%2 == 0 {
			nums = append(nums[:i], nums[i+1:]...)
			i--
		}
	}
	fmt.Println(nums)
}
Text
recovered: runtime error: index out of range [3] with length 3

The same job done with the library, in the version that cannot panic:

main.goGo
package main

import (
	"fmt"
	"slices"
)

func main() {
	nums := []int{1, 2, 3, 4, 5, 6}
	nums = slices.DeleteFunc(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println(nums)
}
Text
[1 3 5]

The loop condition uses the original length while the slice shrinks, so a later pass reads beyond the end. Re-evaluate len(nums) in the condition, iterate backwards, or build the result without mutating, which is simplest: slices.DeleteFunc(nums, func(n int) bool { return n%2 == 0 }) does the whole thing correctly (removing an element from a slice).

Reading the trace for the real cause

The panic line names the numbers, and the stack trace names the function. Open the first frame in your own code and look at the index expression. Ask where the index came from. If it is a loop variable, check the loop bounds. If it is a function parameter, the bug may be in the caller that passed it, one frame down. If it is a computed value such as n/2 or len(a)-k, check the arithmetic for the smallest and largest inputs, because the boundary values are where it breaks: an empty slice, a one-element slice, and a length equal to k. Writing the failing input as a test before the fix keeps the bug from returning, and the table of boundary cases is usually short (testing error paths shows the shape).

What the compiler catches

An index that is a constant is checked at compile time against an array's length:

main.goGo
package main

import "fmt"

func main() {
	var a [3]int
	fmt.Println(a[5])
}
Text
# example.test/lab
./main.go:7:16: invalid argument: index 5 out of bounds [0:3]

That does not run: the compiler rejects it with invalid argument: index 5 out of bounds [0:3]. Only variable indexes, and slices whose length is not known at compile time, reach the run-time check. This is why the bug appears with real data and not in the first tiny test.

Safe access patterns

Four habits remove the bug in practice.

Range instead of index whenever you only need the elements. Guard with len when you must index, and put the guard in the same function as the access. Return an ok or an error from helpers that fetch by position, so callers must handle absence. Use the library: slices.Index, slices.Contains, slices.Max and friends take care of the positions for you.

A generic accessor is a four-line helper for the case where a default is fine:

main.goGo
package main

import "fmt"

// At returns s[i], or def when i is out of range.
func At[T any](s []T, i int, def T) T {
	if i < 0 || i >= len(s) {
		return def
	}
	return s[i]
}

func main() {
	args := []string{"prog", "run"}
	fmt.Println(At(args, 1, "help"), At(args, 5, "help"))
}
Text
run help

When a panic is the right answer

An out-of-range index inside your own algorithm, where the index is computed and the bounds were supposed to hold, is a bug, and the panic is the correct way to say so; do not hide it with a guard that returns a default and carries on with wrong data. Guards are for input you do not control. For invariants you do control, let it panic, and let the stack trace show where the arithmetic broke (panic or error). The Go specification describes the run-time panic in its section on index expressions, and the slices package lists the helper functions.

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.