Slices are Go’s primary sequence type — dynamic, flexible, and built on top of arrays. A slice is a three-word header: a pointer to an underlying array, a length (how many elements are accessible), and a capacity (how many elements the underlying array can hold before reallocation). Most of the slice behavior that surprises developers comes from understanding this header.
For arrays (the fixed-size foundation) see Go arrays creation and indexing.
The Slice Header
s := make([]int, 3, 5)
// s = {ptr: 0x..., len: 3, cap: 5}
// ↑ ↑ ↑
// points to accessible total array
// [0 0 0 0 0] elements capacity
len(s) is what you can access. cap(s) is how much room exists before a new array must be allocated. When you append beyond capacity, Go allocates a larger array, copies, and updates the slice header.
Creating Slices
// Slice literal — len=3, cap=3
s1 := []int{1, 2, 3}
// make(type, len, cap) — cap optional, defaults to len
s2 := make([]int, 5) // len=5, cap=5
s3 := make([]int, 0, 10) // len=0, cap=10 — pre-allocated, empty
// Nil slice — the zero value; len=0, cap=0
var s4 []int // nil, but safe to append to
fmt.Println(s4 == nil) // true
fmt.Println(len(s4)) // 0
s4 = append(s4, 1) // works fine
make([]int, 0, 10) pre-allocates a backing array of 10 elements. No reallocation happens until you append beyond 10. Use this when you know the approximate final size to avoid repeated reallocations.
append: Growth Behavior
append adds elements, returning a new slice header (which may point to a new array if the old one was full):
s := []int{1, 2, 3}
s = append(s, 4) // single element
s = append(s, 5, 6, 7) // multiple elements
other := []int{8, 9}
s = append(s, other...) // append another slice
fmt.Println(s) // [1 2 3 4 5 6 7 8 9]
When capacity is exceeded, Go allocates a new array — currently roughly doubling up to 1024 elements, then growing by ~25%. The old array becomes eligible for GC.
Always reassign s = append(s, ...) — append may return a different slice header if reallocation occurred.
The Sharing Trap
Slices derived from the same array share memory. Modifying one can silently affect the other:
original := []int{1, 2, 3, 4, 5}
sub := original[1:4] // [2 3 4] — shares original's array
sub[0] = 20
fmt.Println(original) // [1 20 3 4 5] — original modified!
This also affects append when capacity allows:
a := make([]int, 3, 5) // [0 0 0], cap=5
b := a[1:3] // [0 0], shares a's array, cap=4
b = append(b, 99) // fits in a's capacity — overwrites a[3]!
fmt.Println(a) // [0 0 0 99 0] — a was modified through b
Fix: use a three-index slice to restrict capacity, forcing append to allocate independently:
b := a[1:3:3] // len=2, cap=2 (not 4)
b = append(b, 99) // cap exceeded — new array allocated
fmt.Println(a) // [0 0 0 0 0] — a is unchanged
copy: Making Independent Copies
copy(dst, src) copies min(len(dst), len(src)) elements and returns the count:
original := []int{1, 2, 3, 4, 5}
// Full copy
clone := make([]int, len(original))
copy(clone, original)
clone[0] = 99
fmt.Println(original) // [1 2 3 4 5] — unchanged
// Partial copy
first3 := make([]int, 3)
copy(first3, original)
fmt.Println(first3) // [1 2 3]
// Copy within a slice (overlapping is handled correctly)
copy(original[1:], original[2:]) // shift left by one
fmt.Println(original) // [1 3 4 5 5]
copy is the correct way to get an independent slice — not b := a (that’s a header copy sharing the same array).
Common Slice Tricks
Remove element at index i (order preserved):
s = append(s[:i], s[i+1:]...)
Remove element at index i (fast, doesn’t preserve order):
s[i] = s[len(s)-1]
s = s[:len(s)-1]
Insert at index i:
s = append(s, 0) // grow by one
copy(s[i+1:], s[i:]) // shift right
s[i] = newVal
Reverse in place:
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
Deduplicate (requires sorted slice):
j := 0
for i := 1; i < len(s); i++ {
if s[i] != s[j] {
j++
s[j] = s[i]
}
}
s = s[:j+1]
Slice of Structs vs Slice of Pointers
A common design question: []User or []*User?
// []User: values stored contiguously — better cache locality, copies on append
users := []User{{Name: "Alice"}, {Name: "Bob"}}
users[0].Name = "Alicia" // direct modification
// []*User: pointers stored contiguously — stable addresses, heap allocation per user
ptrs := []*User{{Name: "Alice"}, {Name: "Bob"}}
ptrs[0].Name = "Alicia" // same modification
Prefer []T for small structs — the contiguous memory is more cache-friendly. Use []*T when:
- You need stable pointers (a pointer to
users[0]becomes invalid afterappendreallocatesusers) Tis large and copying is expensivenilneeds to represent “absent” within the slice
Performance: Pre-allocate When Size Is Known
// ❌ Repeated allocations as slice grows — O(n) allocations
var result []int
for i := 0; i < n; i++ {
result = append(result, expensive(i))
}
// ✅ One allocation — O(1) allocations
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, expensive(i))
}
The difference matters at scale: appending 100k elements without pre-allocation causes ~17 reallocations (doubling from 1 to 131072). With make([]int, 0, 100000), it’s one allocation.
Summary
- A slice is a pointer + len + cap;
lenis accessible elements,capis room before reallocation appendreturns a new header (possibly pointing to a new array); always reassigns = append(s, ...)- Sub-slices share the underlying array — modifying one affects the other; use three-index
a[l:h:m]to control capacity copy(dst, src)makes a true independent copy; simple assignmentb := aonly copies the header- Pre-allocate with
make([]T, 0, n)when final size is known — avoids O(n) reallocations - Use
[]Tfor small structs (cache-friendly);[]*Twhen stable addresses or nil elements are needed
Comments