Slices in Go: len, cap and append pitfalls
1. Overview
Slices are the most widely used data type in Go, but they also cause some of the most difficult-to-detect bugs. The reason is that multiple slices can share the same backing array. In this case, append can overwrite data from another slice without any error reporting.
This article explains len and cap, how append decides whether to allocate a new array or write to the existing one, and techniques to avoid errors: full slice expression, copy, slices.Clone. After reading, you’ll be able to predict the correct outcome of code with append and know how to write tests to catch aliasing bugs.
The examples use Go 1.21 or later, since the slices package is available from this version.
2. What is a slice really?
A slice is a small struct with three fields:
- pointer: points to the first element in the backing array.
- len: the number of elements you can access.
- cap: the number of elements from the pointer to the end of the backing array.
When you pass a slice to a function or assign it to another variable, Go only copies this three-field struct. The backing array is not copied.
2.1. Observing len and cap
package main
import "fmt"
func main() {
orders := []string{"A01", "A02", "A03", "A04", "A05"}
recent := orders[1:3]
fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4
orders[1:3] retrieves elements from index 1 to before index 3, so len is 2. cap is 4 because from the position orders[1] to the end of the array there are 4 slots: A02, A03, A04, A05.
2.2. Two slices, one array
Because recent points to the same array as orders, modifying an element of this slice will immediately be seen in the other slice:
recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]
This is intentional behavior. It allows slicing without overhead. But it’s also the root of all the pitfalls in the following sections.
3. How append works
append(s, x) does one of two things:
- If
len(s) < cap(s): writesxto the slots[len(s)]in the current array, then returns a new slice withlen + 1. - If
len(s) == cap(s): allocates a larger new array, copies old data to it, writesx, then returns a slice pointing to the new array.
Because case 2 can happen, you must always reassign the result: s = append(s, x).
3.1. Observing cap grow
package main
import "fmt"
func main() {
var ids []int
for i := 0; i < 10; i++ {
ids = append(ids, i)
fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
For small slices, cap usually doubles each time it fills up. For large slices, the runtime grows more slowly, and the result is also rounded according to size classes in the allocator.
Note: The formula for increasing
capis a runtime implementation detail and has changed across Go versions. Don’t write code that depends on a specificcapvalue afterappend.
4. Pitfall 1: append overwrites data in the original slice
This is the most common error:
package main
import "fmt"
func main() {
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
}
firstTwo: [10 20 99]
prices: [10 20 99 40 50]
firstTwo has len = 2 but cap = 5. The array has space, so append doesn’t allocate a new array but writes 99 to the third slot of the shared array. As a result, prices[2] changes from 30 to 99.
This bug is very hard to find. The code doesn’t panic, and the overwritten value may only be revealed in a different module, much later.
5. Pitfall 2: two appends from the same slice
package main
import "fmt"
func main() {
base := make([]string, 3, 4)
base[0], base[1], base[2] = "pen", "book", "bag"
cartA := append(base, "laptop")
cartB := append(base, "phone")
fmt.Println("cartA:", cartA)
fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]
base has exactly one empty slot. The first append call writes "laptop" to it. The second call writes "phone" to that same slot. Both cartA and cartB point to the same array, so "laptop" has disappeared.
If base had cap = 3, both append calls would allocate separate arrays and the result would be as expected. Same code, different behavior depending on cap. That’s why this bug only appears occasionally.
6. Pitfall 3: append inside a function doesn’t change the caller’s slice
package main
import "fmt"
func addItem(items []string, item string) {
items = append(items, item)
}
func main() {
cart := make([]string, 0, 10)
addItem(cart, "pen")
fmt.Println(len(cart), cart)
fmt.Println(cart[:1])
}
0 []
[pen]
The addItem function receives a copy of the slice struct. append wrote "pen" to the shared array because there was space. But the new len is only assigned to the local variable items. The cart variable in main still has len = 0. The data is already in the array, but you only see it if you intentionally slice again with cart[:1].
The fix is to return the new slice:
func addItem(items []string, item string) []string {
return append(items, item)
}
// usage
cart = addItem(cart, "pen")
This is also why the standard library designed append as a function that returns a value, rather than modifying the slice in place.
7. Pitfall 4: confusing make with len and make with cap
package main
import "fmt"
func main() {
names := []string{"An", "Binh", "Chi"}
upper := make([]string, len(names)) // wrong intent
for _, n := range names {
upper = append(upper, n+"!")
}
fmt.Println(len(upper), upper)
}
6 [ An! Binh! Chi!]
make([]string, 3) creates a slice with 3 empty elements, not an empty slice with capacity 3. append adds after those 3 empty strings.
You have two correct ways to fix this. Choose one, don’t mix them:
// Way 1: len = 0, cap = n, then append
upper := make([]string, 0, len(names))
for _, n := range names {
upper = append(upper, n+"!")
}
// Way 2: len = n, assign by index
upper := make([]string, len(names))
for i, n := range names {
upper[i] = n + "!"
}
Both allocate exactly once, which is faster than letting append grow cap multiple times.
8. How to prevent
8.1. Full slice expression: limiting cap
The syntax s[low:high:max] sets cap = max - low. When cap equals len, the next append must allocate a new array:
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
firstTwo: [10 20 99]
prices: [10 20 30 40 50]
prices is unaffected. This technique is useful when you return a portion of an internal slice and want callers to append freely without corrupting your data.
8.2. slices.Clone and copy: separating the array
When you need a completely independent copy, use clone:
import "slices"
orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)
snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X
For older Go versions before 1.21, use copy:
snapshot := make([]string, len(orders))
copy(snapshot, orders)
copy returns the number of elements copied, which is the minimum of len(dst) and len(src). So dst must have sufficient len, not just cap. A common mistake is writing copy(make([]string, 0, n), src) and getting back an empty slice.
8.3. Memory leak pitfall
Slicing a small portion from a large array will keep the entire large array in memory:
func firstLine(data []byte) []byte {
for i, b := range data {
if b == '\n' {
return slices.Clone(data[:i]) // don't keep all of data
}
}
return slices.Clone(data)
}
If data is a 100 MB file and you return data[:i] without cloning, the garbage collector cannot free that 100 MB as long as the returned slice is still referenced.
9. Writing tests to catch aliasing bugs
Suppose you have an Inventory type that returns a list of products. You want to ensure that when callers append to the result, they won’t corrupt internal data:
// inventory.go
package inventory
type Inventory struct {
items []string
}
func New(items ...string) *Inventory {
return &Inventory{items: items}
}
func (inv *Inventory) Items() []string {
return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory
import (
"slices"
"testing"
)
func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
inv := New("pen", "book")
inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // intentionally leave extra cap
got := inv.Items()
got = append(got, "laptop")
want := []string{"pen", "book"}
if !slices.Equal(inv.Items(), want) {
t.Fatalf("internal items = %v, want %v", inv.Items(), want)
}
if inv.items[:3][2] != "bag" {
t.Fatalf("backing array was overwritten: %v", inv.items[:3])
}
if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
t.Fatalf("got = %v", got)
}
}
The test intentionally creates an internal slice with cap larger than len, because that’s the condition for the bug to appear. If you change Items() to return inv.items, the append(got, "laptop") call will overwrite "bag" and the test fails at the second assertion.
go test ./...
ok example.com/inventory 0.002s
Note: Aliasing tests must always set up the
len < capcondition. If a slice in the test is created with a literal like[]string{"pen", "book"}, thenlen == cap,appendalways allocates a new array, and the test will pass even if the code has a bug.
10. When NOT to worry
Not every slice needs to be cloned. Unnecessary copying wastes memory and CPU. You can safely share an array when:
- The slice is only read, no one
appends or modifies elements. - The slice is a local variable not shared outside the function.
- You intentionally want to modify in place, for example filtering elements with
s[:0]to reuse the array:
active := orders[:0]
for _, o := range orders {
if o != "CANCELLED" {
active = append(active, o)
}
}
This technique is safe because active never writes past the position being read from orders. After the loop, you should only use active and discard orders.
11. Conclusion
A slice is a struct with pointer, len, and cap, pointing to an array that can be shared. append only allocates a new array when len == cap. Otherwise, it writes directly to the shared array. This is the source of the four pitfalls we encountered: overwriting the original slice, two append calls overwriting each other, append in a function not changing the caller, and confusing len with cap in make.
To be safe, remember a few rules:
- Always write
s = append(s, ...). - Functions that add elements should return a new slice.
- Use
s[low:high:high]when returning internal slices. - Use
slices.Clonewhen you need an independent copy or want to free a large array.
When writing tests, remember to set up the len < cap condition so the bug has a chance to appear.