Slice Internals
Reviewed & published by Brayan K
A slice is a three-word header (pointer, length, capacity) over a backing array, and understanding how append grows, when slices share storage, and how copy breaks that sharing is what separates working Go from buggy Go.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- See the pointer/len/cap slice header
- Watch append grow and reallocate capacity
- Spot shared-backing-array aliasing bugs
- Make independent slices with copy()
- Cap capacity with the full-slice expression
- Use the slices package (Go 1.21+) helpers
1️⃣ The Header & How append Grows
Every slice is a three-word value: a pointer to the backing array, a length, and a capacity. While len < cap, append writes in place; when the array is full, it allocates a bigger one, copies everything over, and returns a slice pointing at the new array. Watch the capacity roughly double (1, 2, 4, 8) — that amortizes the cost of growth, but you shouldn't depend on the exact numbers.
package main
import "fmt"
func main() {
// A slice is a 3-word header: pointer, length, capacity.
// Watch capacity GROW as append outgrows the backing array.
var s []int
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
for i := 0; i < 6; i++ {
s = append(s, i)
fmt.Printf("append %d -> len=%d cap=%d\n", i, len(s), cap(s))
}
}2️⃣ Shared Backing Arrays & Aliasing Bugs
A sub-slice like base[1:3] shares base's backing array — it even inherits its remaining capacity. So writing through the sub-slice mutates base, and an append that stays within capacity will overwrite later elements of base. This is the classic aliasing bug. The cure is copy(dst, src) into a slice you make yourself, which gives a genuinely independent slice.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4, 5}
// A sub-slice SHARES the backing array of base.
sub := base[1:3] // [2 3], len 2, cap 4
fmt.Printf("sub=%v len=%d cap=%d\n", sub, len(sub), cap(sub))
// Writing through sub mutates base — same storage!
sub[0] = 99
fmt.Println("base after sub[0]=99:", base) // [1 99 3 4 5]
// append within capacity ALSO writes into base (aliasing bug).
sub = append(sub, 777) // overwrites base[3]
fmt.Println("base after append:", base) // [1 99 3 777 5]
// copy() makes an INDEPENDENT slice — no aliasing.
indep := make([]int, len(sub))
n := copy(indep, sub)
indep[0] = 0
fmt.Println("copied", n, "indep:", indep, "sub:", sub)
}3️⃣ Full-Slice Expression & the slices Package
The three-index full-slice expression s[low:high:max] caps the capacity at max - low. By making the sub-slice "full," the very next append is forced to reallocate instead of clobbering the parent — a safe way to hand out a view. Since Go 1.21 the slices package adds ergonomic helpers: Sort, Contains, Index, and Equal.
package main
import (
"fmt"
"slices"
)
func main() {
base := []int{1, 2, 3, 4, 5}
// FULL-slice expression s[low:high:max] caps capacity at max-low.
// This forces the NEXT append to reallocate instead of aliasing base.
safe := base[1:3:3] // len 2, cap 2
fmt.Printf("safe len=%d cap=%d\n", len(safe), cap(safe))
safe = append(safe, 100) // reallocates (cap was full)
fmt.Println("base untouched:", base) // [1 2 3 4 5]
// The slices package (Go 1.21+) has handy helpers.
xs := []int{3, 1, 2}
slices.Sort(xs)
fmt.Println("sorted:", xs) // [1 2 3]
fmt.Println("contains 2?", slices.Contains(xs, 2)) // true
fmt.Println("index of 3:", slices.Index(xs, 3)) // 2
fmt.Println("equal?", slices.Equal(xs, []int{1, 2, 3})) // true
}🎯 Your Turn
Make an independent copy so changing dst never touches src. Fill in the two blanks.
package main
import "fmt"
func main() {
src := []int{10, 20, 30}
// 1) Make a destination the same length as src.
dst := ___([]int, len(src)) // 👉 make
// 2) Copy src into dst.
n := ___(dst, src) // 👉 copy
dst[0] = 0 // changing dst must NOT change src
fmt.Println("copied", n, "src:", src, "dst:", dst)
// ✅ Expected output:
// copied 3 src: [10 20 30] dst: [0 20 30]
}Common Errors (and the fix)
- ❌ Append silently corrupted another slice — they shared a backing array. ✅ copy() first, or use s[low:high:max] to cap capacity.
- ❌ copy copied 0 elements — the destination had length 0. ✅ copy moves min(len(dst), len(src)); size dst with make([]T, len(src)).
- ❌ A huge array won't get garbage-collected — a tiny sub-slice still references it. ✅ Copy the needed part into a fresh slice.
- ❌ "slice bounds out of range [::6]" — the max in a full-slice expression exceeds capacity. ✅ Keep max ≤ cap(parent).
🎯 Mini-Challenge: Fast Remove
Implement removeAt using the order-not-preserved trick: copy the last element over index i, then return s[:len(s)-1]. It's O(1) because it avoids shifting.
package main
import "fmt"
// 🎯 MINI-CHALLENGE: remove the element at index i (order NOT preserved).
// The fast trick: copy the LAST element over index i, then shrink the
// slice by one with s[:len(s)-1]. Return the result.
//
// ✅ Expected output: [10 50 30 40]
func removeAt(s []int, i int) []int {
// your code here
return s
}
func main() {
nums := []int{10, 20, 30, 40, 50}
nums = removeAt(nums, 1) // remove the 20
fmt.Println(nums)
}🎉 Lesson Complete!
- ✅ A slice is a header: pointer, length, capacity over a backing array
- ✅ append grows in place until full, then reallocates and copies
- ✅ Sub-slices share storage — aliasing can silently corrupt data
- ✅ copy(dst, src) into a make'd slice breaks the sharing
- ✅ s[low:high:max] caps capacity; the slices package adds helpers
Practice quiz
What three words make up a slice header?
- length, capacity, type
- pointer, type, length
- pointer, length, capacity
- start, end, step
Answer: pointer, length, capacity. A slice is a 3-word header: pointer to the backing array, length, and capacity.
Starting from a nil slice and appending 0,1,2,3,4,5, what capacity does len 3 reach?
- 4
- 3
- 6
- 8
Answer: 4. Growth roughly doubles: cap goes 1, 2, 4 — so at len 3 the cap is 4.
When does append grow the backing array?
- On every call
- Only for slices of structs
- Never — it always reuses
- Only when len equals cap (no room left)
Answer: Only when len equals cap (no room left). If len < cap append writes in place; when full it allocates a bigger array and copies.
For base := []int{1,2,3,4,5}, what is cap(base[1:3])?
- 2
- 4
- 3
- 5
Answer: 4. A sub-slice inherits the parent's remaining capacity: from index 1 to the end is 4.
Why can appending to a sub-slice silently corrupt the parent slice?
- They share a backing array, so an in-capacity append overwrites the parent's later elements
- append always copies
- Sub-slices are read-only
- cap is always 0
Answer: They share a backing array, so an in-capacity append overwrites the parent's later elements. A sub-slice shares storage; an append within capacity writes into the parent's array.
What does the full-slice expression s[low:high:max] control?
- The length only
- The element type
- The capacity, setting it to max-low
- The starting pointer offset only
Answer: The capacity, setting it to max-low. The three-index form caps capacity at max-low, forcing the next append to reallocate.
How do you make a genuinely independent slice that won't alias the original?
- sub := base[:]
- copy into a make'd slice of the right length
- use append(base)
- assign s2 := s1
Answer: copy into a make'd slice of the right length. copy(dst, src) into a make([]T, len(src)) gives independent storage.
How many elements does copy(dst, src) move?
- len(src) always
- len(dst) always
- cap(dst)
- min(len(dst), len(src))
Answer: min(len(dst), len(src)). copy moves min(len(dst), len(src)) elements.
For base := []int{1,2,3,4,5}, what is cap(base[1:3:3])?
- 1
- 2
- 3
- 4
Answer: 2. The full-slice expression sets cap to max-low = 3-1 = 2.
Why might a huge backing array fail to be garbage-collected?
- Slices are never collected
- copy leaks memory
- A small sub-slice still references the entire backing array
- cap is too large
Answer: A small sub-slice still references the entire backing array. A tiny sub-slice keeps the whole backing array alive; copy the part you need to free the rest.
Continue this course
- Previous: Struct Embedding & Composition
- Next: Maps: sets, ordering & nested maps — Sets with struct{}, comma-ok, deterministic printing, and nested maps
Frequently asked questions
What exactly is in a slice header?
A slice value is a small three-word header: a pointer to the backing array, the length (how many elements are currently usable), and the capacity (how many elements the backing array can hold from the pointer onward). Copying a slice copies this header, not the underlying data — which is why two slices can share the same backing array.
How does append decide when to grow capacity?
If the slice has room (len < cap), append writes into the existing backing array in place. If it's full, append allocates a new, larger backing array, copies the old elements over, and returns a slice pointing at the new array. The growth pattern is roughly doubling for small slices (1, 2, 4, 8, ...) and a smaller factor for very large ones, but you should never rely on exact numbers.
Why did appending to one slice change another?
Because both slices share a backing array and the append happened within the existing capacity, so it wrote in place. A sub-slice like base[1:3] keeps base's capacity, so appending to it can overwrite later elements of base. To avoid this aliasing, copy the data into a fresh slice, or use a full-slice expression to cap the capacity.
What is the full-slice expression s[low:high:max] for?
The three-index form sets the capacity of the result to max - low, instead of inheriting the parent's capacity. By capping capacity, you force the very next append to allocate a new backing array rather than writing into the original — a clean way to hand out a sub-slice that can't accidentally clobber its parent.
How do I free a big array when I only need a small part?
A small sub-slice of a huge slice still references the entire backing array, so the garbage collector can't reclaim it. To release the rest, copy the part you need into a new, right-sized slice: small := make([]T, n); copy(small, big[i:i+n]). Now nothing points at the large array and it can be collected.