The Append Gotcha: When Append Modifies the Original Slice
You are debugging a test failure. The test creates a base list of permissions. It passes that list to a function that adds a temporary role. The function returns a new list. The test checks the base list and finds the temporary role is still there. The function did not modify the base list directly. It used append. The bug is invisible to the type system. The compiler did not complain. The behavior depends on a hidden detail: capacity.
Slices are views, not containers
A slice is a lightweight header that points to an underlying array. The header holds three values: a pointer to the array, the current length, and the capacity. The length is how many elements you can see. The capacity is how many elements fit in the array starting from the pointer. When you pass a slice to a function, you copy the header. The pointer, length, and capacity are copied. The underlying array is shared.
Think of a slice as a window into a warehouse shelf. The slice header is a label card: "Start at aisle 4, bin 12. There are 3 items here. The shelf holds up to 10." If you hand someone a copy of the label card, they look at the same shelf. If they add an item to the shelf and there is room, your view changes too. The label card you hold still points to the same shelf.
Convention aside: slices are cheap to pass. They are just three words in memory. Passing a slice by value is the standard way to share data. You do not pass *[]int. You pass []int. The cost is negligible. The risk is shared mutation.
How append works
The append function checks the capacity. If the length plus the new elements fits within the capacity, append writes the new data directly into the existing array. It updates the length and returns a new slice header pointing to the same array. If there is not enough room, append allocates a new, larger array, copies the old data, writes the new data, and returns a slice header pointing to the new array.
The key insight is that append returns a slice. It does not modify the slice header you passed in. It modifies the underlying array when possible. If you ignore the return value, you lose the new length. The original slice header still points to the same array with the same length. The underlying array might have changed, but your view does not reflect it.
The compiler allows you to ignore the result of append. If you write append(s, x) and do not assign it back to s, the append happens in a temporary slice that vanishes immediately. The original slice stays the same. The compiler will not warn you. This is a common source of silent bugs. Always capture the result: s = append(s, x).
The minimal trap
Here is the trap in action. A slice has extra capacity. You create a second slice that shares the array. You append to the second slice. The first slice sees the change.
package main
import "fmt"
func main() {
// Create a slice with length 3 but capacity 5
// The underlying array holds 5 ints, but we only see 3
original := []int{1, 2, 3, 4, 5}[:3]
// Copy the slice header
// shared points to the same array as original
shared := original
// Append fits within capacity
// append writes 99 into the shared array at index 3
// and returns a new header with length 4
shared = append(shared, 99)
// original still has length 3
// but the underlying array changed
// so original sees the new value
fmt.Println(original) // [1 2 3 99]
}
The output shows [1 2 3 99]. The original slice has length 3. It shows elements at indices 0, 1, and 2. Wait. The output is [1 2 3 99]? No. If original has length 3, it prints [1 2 3]. Let me correct the example logic. If original has length 3, fmt.Println(original) prints [1 2 3]. The change is at index 3. original does not see index 3. The bug is that original sees the change only if you access index 3 or if you extend original.
Actually, the bug is more subtle. If original has length 3, it prints [1 2 3]. The 99 is at index 3. original does not include index 3. So original looks unchanged. The bug manifests when you use original in a way that exposes the capacity, or when you append to original and it reuses the slot.
Let me fix the example to show the visible mutation. The mutation is visible if you slice original to expose the capacity, or if you append to original and it overwrites. Or if you pass original to a function that reads the capacity.
Better example: The mutation is visible when you append to original later. Or when you inspect the array. Or when you have a slice that covers the extra capacity.
Revised minimal example:
package main
import "fmt"
func main() {
// Slice with length 3 and capacity 5
base := []int{1, 2, 3, 4, 5}[:3]
// Create a view that shares the array
// view has length 3, capacity 5
view := base
// Append to view
// Fits in capacity, so it writes into the shared array
view = append(view, 99)
// base still has length 3
// But the underlying array at index 3 is now 99
// If we slice base to expose capacity, we see the change
fmt.Println(base[:cap(base)]) // [1 2 3 99 5]
}
The output is [1 2 3 99 5]. The base slice has length 3. base[:cap(base)] creates a new slice with length 5, exposing the full array. The value at index 3 is 99. The view append modified the shared array. The base slice did not change its length, but the data it points to changed. If any code relies on the array being stable, it breaks.
Append is a mutation disguised as a construction. Treat it like a mutation.
Breaking the link
To prevent shared mutation, you need a slice with its own underlying array. The standard way is slices.Clone. This function allocates a new array, copies the elements, and returns a slice pointing to the new array. The new slice has no shared capacity with the original.
Convention aside: the slices package was added in Go 1.21. It is part of the standard library. Use slices.Clone instead of writing manual copy loops. The community standard is slices.Clone for modern Go. If you are on an older version, use copy with make.
package main
import (
"fmt"
"slices"
)
func main() {
// Original slice with extra capacity
original := []int{1, 2, 3, 4, 5}[:3]
// Clone creates a new array and copies the data
// copySlice has its own backing array
copySlice := slices.Clone(original)
// Append to the clone
// This allocates a new array for copySlice
// original is completely unaffected
copySlice = append(copySlice, 99)
// original remains unchanged
fmt.Println(original) // [1 2 3]
fmt.Println(copySlice) // [1 2 3 99]
}
The output shows [1 2 3] and [1 2 3 99]. The original slice is safe. The slices.Clone broke the link to the shared array.
Clone before you append if you share.
Capacity and allocation strategy
When append needs a new array, it does not just add one slot. It over-allocates to reduce future copies. For small slices, the capacity doubles. For larger slices, it grows by a factor of about 1.25. This amortizes the cost of copying. You can see this by printing the capacity after repeated appends.
package main
import "fmt"
func main() {
// Start with capacity 1
s := make([]int, 0, 1)
for i := 0; i < 10; i++ {
s = append(s, i)
// Print length and capacity after each append
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
}
The output shows the capacity jumping: 1, 2, 4, 8, 16. The slice grows exponentially at first. This is intentional. It avoids frequent allocations. The downside is that the slice might have much more capacity than length. If you share this slice, the hidden capacity is larger. The risk of shared mutation is higher.
Capacity is the hidden state. Respect it.
Realistic scenario
You are building a configuration system. You have a base list of settings. You want to create a new configuration by adding overrides. You must not modify the base list.
package main
import (
"fmt"
"slices"
)
// BuildConfig creates a new config from base and overrides
// It returns a new slice and an error if validation fails
func BuildConfig(base []string, overrides []string) ([]string, error) {
// Clone base to avoid mutating the caller's slice
// result has its own backing array
result := slices.Clone(base)
for _, o := range overrides {
// Validate the override
if o == "" {
return nil, fmt.Errorf("empty override not allowed")
}
// Append to result
// result is owned by this function
// so shared mutation is not a concern
result = append(result, o)
}
return result, nil
}
func main() {
base := []string{"debug=false", "log=info"}
overrides := []string{"debug=true", "verbose"}
config, err := BuildConfig(base, overrides)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Base:", base)
fmt.Println("Config:", config)
}
The output shows Base: [debug=false log=info] and Config: [debug=false log=info debug=true verbose]. The base list is unchanged. The function cloned the base before appending. The slices.Clone ensures isolation.
If you forgot the clone, and base had extra capacity, the append would write into the base array. The caller would see the override in the base list. The bug would be silent and hard to trace.
Trust the capacity, not the length. If you share a slice, check the capacity.
Pitfalls and compiler behavior
The compiler does not protect you from shared mutation. It checks types, not intent. If you pass a slice to append, the compiler assumes you know what you are doing. You get no warning about shared capacity.
The compiler rejects append with type mismatches. If you try to append a string to a slice of ints, you get cannot use "x" (untyped string constant) as int value in append. The compiler is strict about types. It is silent about aliasing.
Another pitfall is the variadic form. append(s, other...) appends all elements of other to s. If s and other share the underlying array, the behavior is undefined. The compiler does not check this. The runtime might panic or produce garbage. Do not append a slice to itself.
The copy builtin is safer for overlapping slices. It handles self-copy correctly. append does not. If you need to duplicate elements within the same slice, use copy.
The worst slice bug is the one that only appears under load. When memory pressure is high, allocations behave differently. Capacity growth might trigger at different points. A bug that works in tests might fail in production because the capacity happens to be sufficient in one case and not in another.
Test with slices that have extra capacity. Use make([]T, len, len+10) to force shared capacity in tests. This exposes hidden aliasing bugs.
Decision matrix
Use slices.Clone when you need a completely independent copy of a slice before modifying it.
Use append directly on a slice when you own the slice and do not care about the underlying array being reused.
Use a slice with extra capacity when you want to avoid allocations in a tight loop by reusing the same backing array.
Use make([]T, 0, cap) when you want to create a new slice header that shares capacity but starts empty.
Use the copy builtin when you need to copy elements between slices and want to handle overlapping ranges safely.
Use append with the variadic form append(s, other...) when you are sure s and other do not share the underlying array.