← Debugging Guides

阅读中文版本

Go slice bounds out of range — Off-by-One and Capacity Traps

Diagnose Go slice bounds panics by reading the reported index and capacity, checking len versus cap, and adding explicit guards and edge-case tests.

beginnerdebugginggoslices

Symptoms

  • panic: runtime error: slice bounds out of range [:5] with capacity 3
  • The service crashes on malformed or short input but works on normal payloads
  • The panic appears only under concurrency or after a buffer is reused
  • Stack traces point to a reslicing expression rather than an index access

Possible causes

  • The code assumes a slice has at least N elements without checking len
  • A slice is resliced beyond its length using capacity, then indexed as if full
  • Off-by-one arithmetic uses <= with len or forgets a zero-based boundary
  • A shared buffer is resliced concurrently and its length changes underneath

Diagnosis steps

Reproduce with the smallest failing input

go test ./internal/parse -run TestSplitHeader -v -count=1

Interpretation

The panic message prints the requested bound and the actual capacity. Compare them with len(s) at the call site to see whether the assumption was length or capacity.

Capture every goroutine at panic time

GOTRACEBACK=all go test ./internal/parse -run TestSplitHeader -count=1

Interpretation

Other goroutines that touched the same slice reveal concurrency. A slice shared across goroutines without synchronization can change length between the check and the reslice.

Check the exact expression and indexes

go vet ./... && go test ./... -run TestSplitHeader -race -count=1

Interpretation

The race detector catches unsynchronized buffer reuse. go vet catches some suspicious slicing patterns, though not all off-by-one errors.

Add a temporary bounds check with context

go test ./internal/parse -run TestSplitHeader -count=1 -v 2>&1 | grep -A5 -B5 'slice bounds'

Interpretation

Logging len, cap, and the requested range immediately before the expression shows whether the slice was empty, truncated, or resliced earlier.

Root cause

Go panics when a slice expression asks for an index beyond its capacity, or when a low bound exceeds a high bound. A slice can have len smaller than cap, so s[:cap(s)] is legal but the resulting elements beyond len are not initialized data the application can rely on. Off-by-one comparisons and unchecked input lengths turn a normal empty or short slice into an out-of-range access.

Fix

Validate lengths before slicing, use half-open ranges that cannot exceed len, and prefer the built-in min or the slices package for clamping. Return an error for short input instead of padding silently, and add table tests for empty, one-element, and exactly-boundary inputs. If capacity-based reslicing is intentional, extend with append and then slice only the written portion.

Risk note

Recovering from the panic keeps the process alive but hides the bad input and can leave state inconsistent. Padding a short slice with zero values may produce wrong results instead of a clear error. Clamping silently can mask upstream bugs.

Verify the fix

Run the table tests including empty and short inputs, then the full suite with -race. Confirm the panic no longer occurs and that error paths return a descriptive message rather than a zero-value result.

Read the panic precisely

panic: runtime error: slice bounds out of range [:5] with capacity 3

The message gives the attempted high bound and the slice’s capacity. If the capacity is 3, no expression can produce five elements; the bug is upstream where the length was assumed. A related form, slice bounds out of range [5:3], means the low bound exceeded the high bound.

Find the false assumption

go test ./internal/parse -run TestSplitHeader -v -count=1
GOTRACEBACK=all go test ./internal/parse -run TestSplitHeader -count=1
go test ./... -race -count=1

The stack points at the reslice, but the cause is often earlier: a bytes.Split that returned fewer fields, a read that returned n < len(buf), or a shared buffer resliced by another goroutine.

Guard the boundary

func headerValue(line []byte) (string, error) {
    parts := bytes.SplitN(line, []byte(":"), 2)
    if len(parts) != 2 {
        return "", fmt.Errorf("malformed header: %q", line)
    }
    return strings.TrimSpace(string(parts[1])), nil
}

When clamping is appropriate, use min (Go 1.21+) or slices.Clip rather than hand-written if chains. For a fixed header expected to be five bytes, check len(b) < 5 before slicing and return an error. Do not recover and continue as if the data were valid.

Verify

Add cases for empty input, one element, exactly the boundary, and one element short. Run the tests with -race. The panic should be replaced by a descriptive error for malformed input and correct output for valid input. If the slice comes from a buffer pool, reset its length explicitly before reuse so the next caller cannot inherit stale bounds.