Go — Slice Append Aliasing Corrupts Data
Debug silent data corruption when two slices share a backing array, predict when append copies versus writes in place, and clone slices at ownership boundaries.
Symptoms
- A slice that was sent or stored earlier changes values later, with no writes to it in the code path
- Two logically separate slices show coupled mutations: writing one changes the other
- The bug is data-size dependent: small inputs behave, inputs beyond a threshold corrupt
Possible causes
- A subslice (parent[1:4]) shares the parent backing array; writes through it modify the parent
- append reuses spare capacity of the shared array when it fits, overwriting elements another slice still uses
- A slice is stored in a cache or sent to a channel, and the producer keeps appending to the same header
- A function takes a slice, appends, and ignores that the result may or may not be a new array depending on capacity
Diagnosis steps
Inspect capacity versus length at the boundary
go run -gcflags="-m" ./alias 2>&1 | head; or add printsInterpretation
Printing len and cap of both slices reveals the shared-capacity condition: cap-len headroom on the parent is exactly the room append will reuse.
Prove the sharing with a write probe
go test -run TestAlias -v ./sliceutil/Interpretation
Write through one slice, read the other: changed values prove a shared backing array conclusively.
Find the send/store points
grep -Rn "ch <- \|cache.Set(\|return.*\[" src/ | head -20Interpretation
Every place a slice escapes the current function is an ownership boundary where aliasing becomes a latent bug.
Root cause
Go slices are views over a backing array. Slicing and appending with spare capacity both operate on the same array: append copies only when capacity is exceeded. Two slice headers can therefore reference one array, and a write or in-place append through one header changes what the other header observes.
Fix
Clone at ownership boundaries with an explicit copy (append([]T(nil), s...) or slices.Clone), return append results instead of relying on mutation, use full slice expressions parent[1:4:4] to cap capacity when handing out subslices, and document which functions alias their inputs.
Risk note
Cloning every slice everywhere multiplies allocations and hides the performance contract. Clone at boundaries, alias deliberately inside a single owner.
Verify the fix
Write a test that appends past and within capacity on aliased slices, asserting isolation at boundaries and documented aliasing internally.
Symptom walkthrough
The corruption pattern has a signature: a stored or sent slice mutates without any code writing to it.
batch := make([]Event, 0, 128)
for _, e := range raw {
batch = append(batch, e)
if len(batch) == 128 {
ch <- batch // header escapes; array shared
}
}
The consumer stores the batch. Later appends, while cap permits, write into the same backing array the consumer still reads. The size dependence is the tell: with capacity 128 and batches of exactly 128, the next append allocates a new array — safe. With capacity 256 and batches of 128, the next 128 appends reuse the array — corruption. Predict it with the arithmetic: append copies only when len+1 > cap.
Fix in practice
Clone wherever ownership transfers:
import "slices"
ch <- slices.Clone(batch) // consumer owns its own array
Or when you hand out a window, cut capacity with a three-index slice:
view := parent[low:high:high] // cap == len: append must copy
Inside a single owner, aliasing is fine and fast — document it:
// TrimPrefix aliases src; the result shares its backing array.
func TrimPrefix(src []byte, p string) []byte
The audit that finds latent cases: grep every channel send, cache store, and struct-field assignment of a slice, and ask whether the producer can append afterward. If yes, clone.