- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen the diff. Someone takes a slice, trims it with
data[:n], appends a record, returns it. Tests pass. The reviewer
reads it top to bottom, nods, and approves. Two weeks later a
different request reads a value it never wrote, and nobody can
reproduce it on their laptop.
A Go slice is three words: a pointer to a backing array, a length,
and a capacity. Re-slicing copies those three words. It does not
copy the array. Two slices that share an array are aliases, and
writing through one shows up in the other. That sharing is the
whole point of slices being cheap. It is also where these three
bugs come from. All of them compile, pass go vet, and read as
normal code.
Bug 1: re-slicing hands out a window, not a copy
The first one shows up when a function trims a slice and stores or
returns the result, expecting an independent value.
package main
import "fmt"
func firstTwo(s []int) []int {
return s[:2]
}
func main() {
data := []int{1, 2, 3, 4, 5}
view := firstTwo(data)
view[0] = 99
fmt.Println(data) // [99 2 3 4 5]
}
view looks like a fresh []int{1, 2}. It is not. It is a window
onto the same backing array as data. Writing view[0] writes
data[0]. The caller of firstTwo never asked to touch data,
but it did.
This bites hardest when the slice crosses a boundary you think of
as a copy. You parse a buffer, hand a sub-slice to a goroutine for
processing, and reuse the buffer for the next read. The goroutine
is still reading the array you just overwrote.
The fix is to copy when you need an independent slice. slices.Clone
(Go 1.21+) is the clear way to say it:
import "slices"
func firstTwo(s []int) []int {
return slices.Clone(s[:2])
}
Before 1.21, the idiom was append([]int(nil), s[:2]...), which
allocates a new backing array and copies into it. Either way, the
returned slice owns its memory and the caller's writes stay local.
The rule: re-slicing never copies the array. If a slice is going to
outlive the data it points into, or be written to by a different
owner, clone it.
Bug 2: append writes into the parent's tail
This is the one that survives the most code reviews, because the
behavior changes depending on capacity, and capacity is invisible
in the diff.
package main
import "fmt"
func main() {
original := []int{1, 2, 3, 4, 5}
head := original[:2] // len 2, cap 5
withExtra := append(head, 99) // room in the array, reuses it
fmt.Println(original) // [1 2 99 4 5]
fmt.Println(withExtra) // [1 2 99]
}
head has length 2 but capacity 5, inherited from original.
append(head, 99) checks the capacity, sees room, and writes into
the existing array at index 2. That index is original[2]. It was
- Now it is 99. You appended to one slice and corrupted another.
Flip the capacity and the bug disappears, which is exactly why it
slips through. If head had no spare room, append would allocate
a new array and copy, leaving original untouched:
head := []int{1, 2} // len 2, cap 2
withExtra := append(head, 99) // no room, allocates fresh
// nothing else shares this array
So the same append line is safe or destructive depending on how
the slice was built three functions ago. Your unit test that builds
head with make([]int, 2) passes. Production, where head is a
sub-slice of a larger buffer, fails.
The fix has a name in the spec: a full-slice expression. Add a
third index that caps the capacity at the length.
head := original[:2:2] // len 2, cap 2
withExtra := append(head, 99) // cap is full, must allocate
fmt.Println(original) // [1 2 3 4 5], untouched
original[:2:2] reads as "from 0, length 2, capacity 2." The next
append has no spare room, so it allocates a new array instead of
reaching into original. The third index is the cheapest defense
you have against this bug, and most Go code never uses it.
When you genuinely want an independent slice rather than just a
capped one, clone as in Bug 1. The full-slice expression protects
the parent; slices.Clone gives you a slice that owns its array
outright.
Bug 3: the capacity surprise that holds memory hostage
The last one is quieter. It does not corrupt data. It keeps memory
alive that you thought you had freed, and it shows up as a slow
leak under load.
You read a 10 MB payload, pull out a small header, return the
header, and expect the 10 MB to be collected. It is not.
package main
import "fmt"
func header(payload []byte) []byte {
return payload[:16] // 16-byte header out of 10 MB
}
func main() {
big := make([]byte, 10<<20) // 10 MB
h := header(big)
fmt.Println(len(h), cap(h)) // 16 10485760
}
h has length 16. Its capacity is the full capacity of big,
because re-slicing keeps the same backing array. As long as h is
reachable, the garbage collector cannot free the 10 MB behind it. A
16-byte slice is pinning 10 MB of memory. Do that on every request
and the heap climbs until something falls over.
cap(h) is the tell. The length says 16, the capacity says the bug
is there.
The fix is to copy the bytes you keep into a slice that is sized to
them, then drop the reference to the big one:
func header(payload []byte) []byte {
h := make([]byte, 16)
copy(h, payload[:16])
return h
}
For byte slices specifically, bytes.Clone (Go 1.20+) does the
same thing in one call:
import "bytes"
func header(payload []byte) []byte {
return bytes.Clone(payload[:16])
}
Now the returned slice has length 16 and capacity 16. The 10 MB
backing array has no live references once header returns, so the
collector reclaims it.
The rule: a small slice carved out of a large one keeps the large
one alive. When you retain a sub-slice for longer than the source,
copy the part you need so the source can be freed.
Why all three pass review
The three bugs are the same fact wearing three coats. Re-slicing
copies the slice header and shares the array. From there:
- Bug 1 writes through a shared array and surprises the other owner.
- Bug 2 lets
appendwrite into shared capacity and corrupt the parent's tail. - Bug 3 keeps a large shared array alive through a tiny window.
A reviewer reading the diff sees s[:2], append(head, x),
payload[:16]. None of those look wrong, because none of them are
wrong on their own. The bug is the relationship between the slice
and the array it points into, and that relationship is not in the
diff. Capacity is the missing variable, and capacity is exactly
what the source text hides.
What to grep for on Monday
Three patterns to search the codebase you already have.
- A function that returns or stores
s[:n](ors[i:j]) and a caller that mutates the result or keeps it past the source. That is Bug 1. Clone at the boundary. -
append(where the first argument is a sub-slice of something that lives longer. That is Bug 2. Use a full-slice expressions[:n:n]at the point you take the sub-slice, or clone it. - A small slice retained out of a much larger one, especially byte
buffers from network reads or file reads. Check
cap()on the thing you keep. If the capacity dwarfs the length, that is Bug 3. Copy withbytes.Cloneorslices.Clone.
None of these need a new tool. They need you to remember that a
slice is a view, and a view shares.
If this was useful
Slice aliasing is one of those Go topics that looks like a footnote
until you trace a real corruption bug back through three function
calls to a sub-slice nobody thought twice about. The Complete
Guide to Go Programming walks through the slice header, the
backing-array model, and what append, capacity, and full-slice
expressions actually do at the memory level. If you understand the
three words a slice is made of, these three bugs stop being
surprises.

Top comments (0)