Slice Header Copy & Append Surprise
Two functions share what looks like independent slices until one append overwrites the other's elements.
This page shows how slice header copying creates aliasing, how append reuses capacity, and the copy patterns that prevent production data corruption.
Summary
A Go slice is a small header: pointer, length, and capacity.
Assignment and passing by value copy the header, not the underlying array.
When two headers point at the same array, append on one slice can mutate elements visible through the other if spare capacity exists.
These bugs are insidious because programs rarely panic - they return wrong data under specific sizes and load patterns.
Recipe
Quick-reference recipe card - copy-paste ready.
// Limit capacity when exposing an interior view
sub := s[low:high:high] // cap == len, append reallocates
// Defensive copy before handing to caller
out := append([]T(nil), s...)
// Copy prefix only
prefix := append([]T(nil), s[:n]...)When to reach for this:
- Returning subslices from package APIs
- Parsing protocols into views over a read buffer
- Pooling
[]bytebuffers reused across requests - Any
appendafters[:n]on shared storage - Code review of
[]bytehandling in HTTP/gRPC layers
Working Example
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4, 5}
window := base[1:3] // len 2, cap 4 (shares backing array)
fmt.Println("before", base, window, "cap", cap(window))
window = append(window, 99) // writes into base's backing array
fmt.Println("after ", base, window)
safe := append([]int(nil), base[1:3]...)
safe = append(safe, 99)
fmt.Println("safe ", base, safe)
}What this demonstrates:
windowsharesbase's array; append with spare capacity mutatesbasecap(window)larger thanlen(window)is the danger signal- Copying before append isolates mutations
- Logging
len,cap, and full backing slice catches the defect in tests
Deep Dive
How It Works
- Slice header:
pointer | len | cap s[low:high]sets len=high-low, cap=cap(s)-low- often larger than lenappendreuses backing array whenlen < cap; otherwise allocates new array- Sub-slice with shared cap means append may clobber indices beyond the sub-slice len but inside parent cap
Safe Patterns
| Goal | Pattern |
|---|---|
| Expose read-only view | Return copy append([]T(nil), s...) |
| Interior view without append growth into parent | s[i:j:j] three-index slice |
| Truncate but keep cap | s = s[:n:n] before passing on |
| Reset reusable buffer | s = s[:0] only when sole owner |
Go Notes
// Parser keeping one []byte buffer
func parseFrame(buf []byte) (payload []byte, rest []byte) {
n := int(buf[0])
payload = append([]byte(nil), buf[1:1+n]...) // copy payload out
rest = buf[1+n:]
return payload, rest
}Gotchas
- Assuming
appendalways allocates - It reuses capacity when possible. Fix: checkcapor copy first. - Returning
s[:n]from internal buffer - Callers append into your pool. Fix: copy or three-index cap limit. bytes.Buffer.Bytes()aliasing - Bytes remain valid until next buffer mutation. Fix: copy if storing long-lived.- Sub-slice of sub-slice cap growth - Cap can extend to end of original array. Fix: full slice expression
s[low:high:high]. - Concurrent append on shared slice - Data race even without panic. Fix: mutex, copy-on-write, or per-goroutine buffer.
- JSON unmarshal into existing slice - Reuses backing array when non-nil. Fix:
s = nilbefore unmarshal if you need fresh allocation.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Three-index slice | Zero-copy view, no append by consumer | Consumer needs append headroom |
append([]T(nil), s...) | Handing ownership to caller | Hot path needs zero-copy and contract forbids append |
slices.Clip (Go 1.21+) | Trim cap to len in one call | Must support older Go without polyfill |
Separate []byte pool per goroutine | High-throughput parsers | Simple code paths where copy is cheap |
FAQs
Why does Go copy the header but not the array?
Slices are lightweight views.
Copying the array on every assignment would destroy performance.
Sharing is explicit via capacity rules.
How do I spot aliasing in code review?
Look for s[low:high] stored or returned, then append on the derived slice.
Check whether parent slice is still live.
Does range copy slice elements?
Range over slice copies each element value.
Mutating struct fields via range value does not update the slice; index assignment does.
Are string subslices safe?
Strings are immutable.
Substrings share backing bytes but cannot be appended to.
What does slices.Clone do?
slices.Clone(s) allocates a new slice with elements copied.
Use when you want stdlib helper instead of append([]T(nil), s...).
Can this bug appear in [][]byte?
Yes - outer and inner slices both have headers.
Copy inner slices when handing rows to async work.
How do I test for aliasing?
Mutate derived slice, assert parent unchanged.
Table-test with lengths where cap > len.
Does append change the parent's len?
No - only capacity sharing affects elements within cap.
Parent len unchanged unless you assign back to parent variable.
Is bytes.Reader safer than raw slices?
Reader manages position but underlying slice may still alias.
Copy if consumers retain []byte views.
What log line helps in incidents?
log.Printf("base=%p len=%d cap=%d %#v", &base[0], len(base), cap(base), base) before and after append on derived slice.
Do generics change slice rules?
No - slice headers behave the same for []T with any T.
When is zero-copy required?
Hot parsers and network stacks.
Document that consumers must not append; enforce with three-index cap or opaque type.
Related
- Slice Internals - pointer, len, cap model
- Debugging Go: Observable Failures and Sharp Edges - silent corruption family
- Data Race Scenarios & race Detector Fixes - concurrent slice access
- Go Debugging Basics - log len and cap
- Data Types Best Practices - copy before handoff rules
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).