🧠 Go Slices Internals: Why Your Slice Might Be Lying to You
If you’ve ever changed one slice and watched another slice mysteriously change too… you’re not alone. Slices in Go are deceptively simple on the surface — but under the hood, they can trip up even seasoned developers.
This post takes a deep look into how Go slices work internally — with visuals, examples, and real-world implications. Let’s go under the hood.
🚧 TL;DR: What You’ll Learn
- How slices actually store data (and what that means for memory sharing)
- What
len
andcap
really mean - When
append()
allocates a new array (and when it doesn’t) - Common bugs caused by misunderstood usage of slices — and how to avoid them
🔍 Slice Internals: What Is a Slice?
A slice in Go is not an array. It’s a lightweight data structure that describes a portion of an array.
Under the hood, it looks like this:
type sliceHeader struct {
Data uintptr // pointer to underlying array
Len int // number of elements in the slice
Cap int // capacity from start to end of backing array
}
That means multiple slices can point to the same underlying array.
🧠 Remember: Slices are wrappers around arrays, not independent copies.
➕ When Does append() Reallocate?
Let’s look at another classic gotcha:
func main() {
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 99)
fmt.Println("a:", a) // 🤔
fmt.Println("b:", b)
}
What happens?
It depends on the capacity of a. If there’s room, append reuses the backing array. If not, it allocates a new one.
fmt.Printf("cap(a): %d\n", cap(a)) // Let's say cap(a) == 3
In this case, cap(b)
is still 3, and appending a third element does not exceed the capacity, so the underlying array of a
slice is reused and updated too, which means result of two Println
functions would result in:
a: [1 2 99]
b: [1 2 99]
Had you reached the capacity limit, Go would allocate a new array silently. This behaviour causes subtle bugs if you’re not paying attention.
🧱 Making an Independent Copy
To avoid shared memory issues, always make a copy when you intend to work independently:
b := make([]int, len(a))
copy(b, a)
This ensures that b has its own backing array, which secures b slice of being accidentally modified. This is the best practice when your function accepts the slice as argument and modifies its content.
🔍 Visual Summary
Here’s an ASCII diagram of what’s going on under the hood in the code snippet mentioned above, after b
becomes a sub-slice of the original slice a
:
Memory: [1][2][3]
Slice a: ^-----^ len=3 cap=3
Slice b: ^--^ len=2 cap=3
Slice b
is a sub slice of a
which means it references original array, even though it does not have the same length.
Insight: you can actually access the slice
b
elements at index2
and3
, however not by direct referencing via index, but as part of a sub-slice, likeb[:3]
🧯 Real Interview Story: Append Inside a Function
Here’s a deceptively simple example which perfectly illustrates how important is it to understand how slices work internally — and it’s one of the most common misunderstandings for newcomers to Go slices:
func mod(s []int) {
s = append(s, 4) // modifying slice with new values.
}
func main() {
s := make([]int, 0, 4) // Preallocate space (len=0)(cap=4)
s = append(s, 1, 2, 3) // populating values
fmt.Println(s) // [1 2 3]
mod(s)
fmt.Println(s[:len(s)+1]) // will it panic ?💥
}
🧠 What’s Happening?
When you pass a slice to a function, Go copies the slice structure. Inside mod function, go runtime checks the len
of the slice before appending a new value and compares it to cap
.
SInce len
value is less then cap
there is no need to allocate new array. New value is simply appended to the underlying array, while Len
field of the sliceHeader now equals 4
.
✨ But here is the catch. ✨
Since array is an underlying structure of sliceHeader and no new allocation was mad, it keeps the value which we put in it in mod
function even after function returned. The only thing which didn’t change is Len
field of sliceHeader itself, since mod function operated on copy of the sliceHeader not original value 🙂.
That means that when we return from mod
function, and call len(s)
it will return 3
, but now we know why it happens, and that s
is actually . . . of length 4
.
Which means, when try to execute:
fmt.Println(s[:len(s)+1])
it will not panic , but return slice with the value we put in it in mod
function.
Can you feel, how good is it to understand these little details ? 🤌
📌 Takeaway
Appending to a slice inside a function does mutate the original underlying array if it has enough space left.
This is a huge source of bugs if you don’t pay attention — and a classic Go interview question which got me with pants down 😅
✅ Final Takeaways
- Slices are wrappers (views) over an underlying array.
- Multiple slices can share the same underlying array.
-
append()
may or may not allocate a new array — it’s capacity-dependent. - Use
copy()
if you need a truly independent slice or function must return modified version of passed slice. - Be cautious when passing slices to functions. Slices are wrapping around arrays, which values still can be modified and lead to bugs 🪲
Top comments (0)