Skip to content

Bounds checks, and how to drop them

Level: 301 · anyone who has read that Go is slow "because of bounds checks" and wants to see one

One line: Every s[i] the compiler cannot prove in range keeps a run-time check, -gcflags=-d=ssa/check_bce prints one Found IsInBounds line for each check it kept, and the compiler's prove pass drops a check it can see is redundant — a range loop's index, a masked index into an [8]int, and any fixed index after a _ = s[3] hint — so a four-byte checksum goes from four checks to one, and a loop from one to none.

A frame checksum written two ways, a sum written two ways, a histogram indexed two ways, and a slice expression. Each function carries //go:noinline so that its checks are counted in it, once, rather than again wherever main would have inlined it. The script builds the file with the debug flag, prints the compiler's lines, then counts them per function:

Verified output of bounds_checks_and_how_to_drop_them_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go build -gcflags=-d=ssa/check_bce . 2>&1
# example
./main.go:7:10: Found IsInBounds
./main.go:7:17: Found IsInBounds
./main.go:7:24: Found IsInBounds
./main.go:7:31: Found IsInBounds
./main.go:14:7: Found IsInBounds
./main.go:35:20: Found IsInBounds
./main.go:57:10: Found IsInBounds
./main.go:66:14: Found IsSliceInBounds
exit status 0
$ checks kept, per function:
  checksumUnchecked  4
  checksumHinted     1
  sumRange           0
  sumIndexed         1
  histogramMasked    0
  histogramModulo    1
  header             1
  main               0

Reading the output

One line per check kept, with the position of the index expression. IsInBounds is the SSA operation that compares an index against a length and panics when it fails; IsSliceInBounds is its cousin for s[:n]. The flag -d=ssa/check_bce asks the compiler to print every one of them that survived to code generation (checkbce.go ↗). Nothing here runs the program: the lines come from the build.

checksumUnchecked: four. Line 7, columns 10, 17, 24 and 31 — one for each of s[0], s[1], s[2] and s[3]. The compiler knows nothing about len(s) when the function starts, and every index is checked on its own.

checksumHinted: one. _ = s[3] on line 14 is a real index expression and keeps a real check, the line at 14:7. After it, the prove pass (prove.go ↗) knows that len(s) > 3, and s[0] through s[3] need nothing. The hint is not a comment or a directive: it is the statement "this function panics right here if the slice is shorter than four", moved to the top. The standard library writes it the same way — encoding/binary ↗ puts _ = b[3] before reading four bytes, with a comment pointing at issue 14808 ↗.

sumRange: none. sumIndexed: one. for i := range readings makes i run from 0 to len(readings)-1 by construction, and readings[i] needs no check. for i := 0; i < n; i++ with an n the caller chose is a different matter: nothing says n ≤ len(readings), and one check stays, at 35:20 — inside the loop, run every iteration.

histogramMasked: none. histogramModulo: one. code&7 is in 0..7 whatever code is, and an [8]int has exactly those indexes. code%8 is in −7..7 for a signed int, because Go's % takes the sign of the dividend (spec ↗), so buckets[code%8] could be buckets[-3] and the check stays. (With an unsigned code, %8 is provable too — not machine-checked here.)

header: one IsSliceInBounds. frame[:n] checks n against the capacity. main: none — its indexes are constants into arrays whose length is part of the type.

What a check costs

Nothing in the key says how much a check costs, because the answer is a time. The demo benchmarks the two checksums and two loops over a 1024-byte frame:

Real runs — go1.25.5, x86-64 Mac, -count=3, 2026-09-23
$ go test -bench=. -count=3 -run='^$'
goos: darwin
goarch: amd64
pkg: example
cpu: Intel(R) Core(TM) i5-10500 CPU @ 3.10GHz
BenchmarkChecksumUnchecked-12       507722650            2.127 ns/op
BenchmarkChecksumUnchecked-12       615493788            2.114 ns/op
BenchmarkChecksumUnchecked-12       525901501            2.406 ns/op
BenchmarkChecksumHinted-12          539282762            2.132 ns/op
BenchmarkChecksumHinted-12          576077798            2.235 ns/op
BenchmarkChecksumHinted-12          577861047            1.921 ns/op
BenchmarkXorIndexed-12               2362429           509.9 ns/op
BenchmarkXorIndexed-12               2214859           535.3 ns/op
BenchmarkXorIndexed-12               2300491           550.4 ns/op
BenchmarkXorRange-12                 3563870           340.6 ns/op
BenchmarkXorRange-12                 3382105           352.8 ns/op
BenchmarkXorRange-12                 3417374           356.3 ns/op
PASS
ok      example 15.586s

Four checks or one, the checksum takes about 2 ns either way: a check is a compare and a branch that is never taken, and the processor predicts it. The indexed loop over 1024 bytes ran about half again as long as the range loop on this machine; it carries one more compare-and-branch per byte, and whether the gap is that check alone or also the loop's shape is a question for the assembly (go build -gcflags=-S), not for this page. The rule is the order of the two tools: check_bce says where the checks are, a benchmark says whether they matter. Run bash demo/bce_bench.sh from this folder to see both on your machine.

bounds_checks_and_how_to_drop_them_sh.sh

bounds_checks_and_how_to_drop_them_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Every s[i] the compiler cannot prove safe keeps a bounds check, and
# -gcflags=-d=ssa/check_bce prints one line per check it kept. This script
# builds a file of seven small functions, prints those lines, then counts them
# per function. The functions are marked go:noinline so that each check is
# reported once, in the function itself, and not again wherever main inlined it.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1

cat >main.go <<'GO'
package main

// Four indexes, four checks.
//
//go:noinline
func checksumUnchecked(s []byte) byte {
    return s[0] ^ s[1] ^ s[2] ^ s[3]
}

// The hint checks index 3 once; the four reads after it need no check.
//
//go:noinline
func checksumHinted(s []byte) byte {
    _ = s[3]
    return s[0] ^ s[1] ^ s[2] ^ s[3]
}

// The range loop's i is within the slice by construction.
//
//go:noinline
func sumRange(readings []int) int {
    total := 0
    for i := range readings {
        total += readings[i]
    }
    return total
}

// n is unrelated to len(readings), so every readings[i] is checked.
//
//go:noinline
func sumIndexed(readings []int, n int) int {
    total := 0
    for i := 0; i < n; i++ {
        total += readings[i]
    }
    return total
}

// code&7 is 0..7, which fits an [8]int exactly.
//
//go:noinline
func histogramMasked(codes []int) [8]int {
    var buckets [8]int
    for _, code := range codes {
        buckets[code&7]++
    }
    return buckets
}

// code%8 can be negative for a negative code, so the check stays.
//
//go:noinline
func histogramModulo(codes []int) [8]int {
    var buckets [8]int
    for _, code := range codes {
        buckets[code%8]++
    }
    return buckets
}

// A slice expression is checked too: IsSliceInBounds.
//
//go:noinline
func header(frame []byte, n int) []byte {
    return frame[:n]
}

func main() {
    frame := []byte{1, 2, 3, 4, 5, 6, 7, 8}
    println(checksumUnchecked(frame), checksumHinted(frame), len(header(frame, 4)))
    readings := []int{3, 1, 4}
    println(sumRange(readings), sumIndexed(readings, 3))
    println(histogramMasked(readings)[3], histogramModulo(readings)[3])
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1

say 'go build -gcflags=-d=ssa/check_bce . 2>&1'
go build -gcflags=-d=ssa/check_bce . >bce.txt 2>&1
status=$?
cat bce.txt
echo "exit status $status"

# Count the checks per function: a check on line L belongs to the last
# `func` declared at or before L.
say 'checks kept, per function:'
awk -F: '
FNR == NR { if ($0 ~ /^func /) { n++; name[n] = $1; sub(/^func /, "", name[n]); sub(/\(.*/, "", name[n]); start[n] = FNR }; next }
/Found Is/ { line = $2 + 0; for (i = n; i >= 1; i--) if (line >= start[i]) { count[name[i]]++; break } }
END { for (i = 1; i <= n; i++) printf "  %-18s %d\n", name[i], count[name[i]] + 0 }
' main.go bce.txt

What to do

  • Ask before you rewrite. go build -gcflags=-d=ssa/check_bce ./... 2>&1 | grep Found lists every check in a package with its position; most are in code that runs once.
  • Prefer for i := range s and for _, v := range s. Both are provably in range; a for i := 0; i < n; i++ over a slice is not, unless n is len(s) in the same function.
  • Hint with the highest index once — _ = s[3] at the top of a function that reads fixed offsets — and keep the comment encoding/binary uses, so the next reader does not delete it as dead code.
  • Mask into a power-of-two table, code&7, when the index can be negative; code%8 cannot be proven.
  • Keep the panic. The check is the safety, and the kata below shows it firing; unsafe is a different chapter, unsafe.Pointer reinterprets memory, and not the next step after this one.

Practice

Add the _ = s[3] hint to a four-byte checksum, then prove the safety net is still there and now sits on the hint line: call both versions with a two-byte slice, recover the panics, and print them. The index in each message says which check fired first.

Solution

bounds_checks_and_how_to_drop_them_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: add the `_ = s[3]` hint to a four-byte checksum, then prove the safety
// net is still there and now sits on the hint line: call both versions with a
// two-byte slice and recover the panics. The index in each message says which
// check fired first.
//
//  go build bounds_checks_and_how_to_drop_them_kata_go.go && ./bounds_checks_and_how_to_drop_them_kata_go
package main

import "fmt"

func checksum(s []byte) byte {
    return s[0] ^ s[1] ^ s[2] ^ s[3]
}

func checksumHinted(s []byte) byte {
    _ = s[3] // one check here; the four below are proven safe by it
    return s[0] ^ s[1] ^ s[2] ^ s[3]
}

// panicOf runs f and returns the panic it raised, or "none".
func panicOf(f func()) (value any) {
    defer func() {
        if r := recover(); r != nil {
            value = r
        }
    }()
    f()
    return "none"
}

func main() {
    frame := []byte{0x5a, 0xa5, 0x0f, 0x01}
    fmt.Printf("four bytes: checksum %#x, hinted %#x\n", checksum(frame), checksumHinted(frame))

    short := frame[:2]
    fmt.Println("two bytes, checksum:      ", panicOf(func() { checksum(short) }))
    fmt.Println("two bytes, checksumHinted:", panicOf(func() { checksumHinted(short) }))
}

Verified output of bounds_checks_and_how_to_drop_them_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

four bytes: checksum 0xf1, hinted 0xf1
two bytes, checksum:       runtime error: index out of range [2] with length 2
two bytes, checksumHinted: runtime error: index out of range [3] with length 2

Without the hint the first index past the end is s[2], and the message says [2] with length 2; with it, the hint's own s[3] fires first, [3] with length 2. The message text is the runtime's, from panic.go ↗, and the spec's index expressions ↗ are what promise the panic in the first place.

In other languages

  • C has no check to drop: the C library's A length you did not check ↗ shows a read past the end of a buffer simply happening, which is the bug Go's IsInBounds exists to turn into a panic.
  • Rust checks every index as Go does and drops checks the same way — iterators instead of indexes are its range loop — and offers get_unchecked ↗ inside unsafe for the cases the optimizer cannot prove; Go has no equivalent short of unsafe.Pointer arithmetic.

Sources