This content originally appeared on DEV Community and was authored by Anu Madhav
Go slices are one of the most powerful features of the language, but they can lead to surprising behavior if you don’t understand how they work under the hood. Let’s explore why slices behave the way they do and what’s really happening in memory.
The Slice Structure
A slice in Go is not just an array – it’s a data structure that contains three components:
type slice struct {
array unsafe.Pointer // pointer to underlying array
len int // length of slice
cap int // capacity of slice
}
This structure is the key to understanding slice behavior. When you create a slice, you’re creating a header that points to an underlying array.
The Unexpected Behavior
Consider this code that demonstrates the surprising behavior:
`package main
import “fmt”
func main() {
cart := []string{“Apple”, “Banana”, “Orange”}
// Appending value into the slice
cart = append(cart, "Milk")
fmt.Println("cart", cart)
// Slicing the slice array
fmt.Println("[:3]", cart[:3])
fruit := cart[:3]
fruit = append(fruit, "lemon")
fmt.Println("fruit", fruit)
fmt.Println("cart", cart)
}`
Output:
cart [Apple Banana Orange Milk]
[:3] [Apple Banana Orange]
fruit [Apple Banana Orange lemon]
cart [Apple Banana Orange lemon]
Wait, what? Why did cart change when we only modified fruit?
What’s Really Happening
1. Initial State
When cart is created with 4 elements, Go allocates an underlying array with enough capacity (likely 4 or more).
2. Slicing Operation
fruit := cart[:3]
This creates a new slice header fruit that points to the same underlying array as cart. The fruit slice just has a different length (3 instead of 4).
3. The Append Operation
fruit = append(fruit, "lemon")
Since fruit has length 3 but the underlying array has capacity for 4 elements, append doesn’t need to allocate a new array. It
simply:
- Places “lemon” at index 3 of the existing array
- Updates the fruit slice’s length to 4
4. Shared Memory
Both cart and fruit point to the same underlying array, so when the array is modified, both slices reflect the change.
Visualizing Memory Layout
Initial cart: [Apple][Banana][Orange][Milk]
↑
cart points here (len=4, cap=4)
After fruit := cart[:3]:
[Apple][Banana][Orange][Milk]
↑ ↑
fruit points here cart points here
(len=3, cap=4) (len=4, cap=4)
After fruit = append(fruit, “lemon”):
[Apple][Banana][Orange][lemon]
↑ ↑
fruit points here cart points here
(len=4, cap=4) (len=4, cap=4)
Is This a Bug?
No, this is not a bug. This is intentional behavior designed for performance. Slices are designed to be lightweight views into arrays, avoiding unnecessary copying.
Performance Benefits
- Memory Efficiency: Multiple slices can share the same underlying array
- Speed: No copying of data when creating sub-slices
- Flexibility: Easy to work with different “windows” of the same data
How to Avoid Unexpected Behavior
1. Use copy() for Independence
fruit := make([]string, 3)
copy(fruit, cart[:3])
fruit = append(fruit, "lemon")
// Now cart remains unchanged
2. Force New Allocation
fruit := append([]string(nil), cart[:3]...)
fruit = append(fruit, "lemon")
// This creates a completely new slice
3. Use Full Slice Expression
fruit := cart[:3:3] // [low:high:max]
fruit = append(fruit, "lemon")
// This limits capacity, forcing new allocation
Key Takeaways
- Slices are headers containing a pointer, length, and capacity
- Sub-slices share the same underlying array
- append() may modify the shared array if there’s sufficient capacity
- This behavior is by design for performance, not a bug
- Use copy() or full slice expressions when you need independent slices
Understanding these internals will help you write more predictable Go code and avoid common pitfalls when working with slices.
Further Reading
Go Blog: Go Slices: usage and internals
Effective Go: Slices
Go Specification: Slice types
This content originally appeared on DEV Community and was authored by Anu Madhav