Slices
Slices are the most important data structure in Go. Understanding their internal three-word header, how append works, and the subtle pitfalls of memory sharing is essential for writing correct and performant Go code.
π― Learning Objectives
- Explain the slice header: pointer, length, and capacity
- Understand how append works and when it allocates a new array
- Identify when two slices share an underlying array
- Use
copyto create truly independent slices - Explain the slice growth algorithm
- Use common slice patterns (append, delete, insert, filter)
- Avoid memory leaks from slice references
π¬ Slice Internals β The Three-Word Header
A slice is not an array. It's a small header struct with three fields that describe a window into an underlying array:
package main
import (
"fmt"
"unsafe"
"reflect"
)
func main() {
s := []int{10, 20, 30, 40, 50}
// len: number of elements visible through this slice
// cap: number of elements from slice's start to end of backing array
fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) // len=5 cap=5
// Inspect the slice header using reflection
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
fmt.Printf("ptr=%x len=%d cap=%d\n", h.Data, h.Len, h.Cap)
// Nil slice vs empty slice
var nilSlice []int // nil: ptr=0 len=0 cap=0
emptySlice := []int{} // not nil: ptrβ 0 len=0 cap=0
emptySlice2 := make([]int, 0) // not nil: ptrβ 0 len=0 cap=0
fmt.Println("nil:", nilSlice == nil) // true
fmt.Println("empty:", emptySlice == nil) // false!
fmt.Println("empty2:", emptySlice2 == nil) // false!
// Both nil and empty slices behave the same for: len, cap, range, append
fmt.Println("len(nil):", len(nilSlice)) // 0
fmt.Println("range nil:")
for range nilSlice { fmt.Println("never") } // never prints
// But nil matters for: JSON encoding, == nil checks
// json.Marshal(nilSlice) β "null"
// json.Marshal(emptySlice) β "[]"
}ποΈ Creating Slices
package main
import "fmt"
func main() {
// From literal
a := []int{1, 2, 3, 4, 5}
// make(type, len): len elements, all zeroed, cap=len
b := make([]int, 5)
fmt.Println(b) // [0 0 0 0 0]
// make(type, len, cap): len visible elements, cap total capacity
// Use this when you know the final size β avoids reallocations
c := make([]int, 0, 10)
fmt.Printf("len=%d cap=%d\n", len(c), cap(c)) // len=0 cap=10
// Slicing an existing slice (or array)
d := a[1:4] // elements at index 1, 2, 3 (not 4!)
fmt.Println(d) // [2 3 4]
fmt.Printf("len=%d cap=%d\n", len(d), cap(d)) // len=3 cap=4
// a[low:high] β shares underlying array with a!
// len = high - low
// cap = cap(a) - low
// Slice of slice
e := a[:] // all elements: [1 2 3 4 5]
f := a[:3] // first 3: [1 2 3]
g := a[2:] // from index 2: [3 4 5]
fmt.Println(e, f, g)
// Three-index slice: a[low:high:max] β controls capacity
h := a[1:3:4] // len=2, cap=3 (max-low = 4-1 = 3)
fmt.Printf("h=%v len=%d cap=%d\n", h, len(h), cap(h))
_ = b; _ = c; _ = d; _ = e; _ = f; _ = g; _ = h
}π Append & Growth Algorithm
append(s, elem) adds elements to a slice. When the slice has enough capacity,
it extends len in place. When capacity is exceeded, it allocates a new, larger backing array
and copies all elements.
package main
import "fmt"
func main() {
// Basic append
s := []int{1, 2, 3}
s = append(s, 4) // append one element
s = append(s, 5, 6, 7) // append multiple elements
s = append(s, []int{8, 9, 10}...) // append a slice
fmt.Println(s) // [1 2 3 4 5 6 7 8 9 10]
// Watch capacity grow
s2 := make([]int, 0)
for i := 0; i < 16; i++ {
s2 = append(s2, i)
fmt.Printf("len=%d cap=%d\n", len(s2), cap(s2))
}
// Cap goes: 1, 2, 4, 8, 16 β typically doubles
// Critical: always use the return value of append!
// The original slice may be replaced by a new one
original := []int{1, 2, 3}
_ = append(original, 4) // β BUG! Return value discarded
fmt.Println("original:", original) // [1 2 3] β 4 was not saved!
// β
Correct:
original = append(original, 4)
fmt.Println("correct:", original) // [1 2 3 4]
// Pre-allocate for performance when you know the final size
const n = 1_000_000
s3 := make([]int, 0, n) // allocate once, no reallocations
for i := 0; i < n; i++ {
s3 = append(s3, i)
}
fmt.Println("len:", len(s3))
}Growth Algorithm
The Go runtime's growth algorithm (as of Go 1.18+):
- If
newLen > 2 * oldCap: new cap = newLen - If
oldLen < 256: new cap = 2 Γ oldCap (doubles) - Otherwise: new cap grows from 2Γ towards 1.25Γ as sizes increase
This amortised growth ensures append is O(1) amortised β the occasional O(n) copy is rare enough that the average per-element cost is O(1).
π¨ Common Slice Patterns
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
// ββ Delete element at index i (order preserved) ββ
i := 2
s = append(s[:i], s[i+1:]...) // [1 2 4 5]
fmt.Println("after delete:", s)
// ββ Delete element (order NOT preserved β faster) ββ
s = []int{1, 2, 3, 4, 5}
i = 2
s[i] = s[len(s)-1] // copy last to position i
s = s[:len(s)-1] // shrink length
fmt.Println("unordered delete:", s) // [1 2 5 4]
// ββ Insert at index i ββ
s = []int{1, 2, 3, 4, 5}
i = 2
s = append(s[:i], append([]int{99}, s[i:]...)...)
fmt.Println("after insert:", s) // [1 2 99 3 4 5]
// ββ Reverse ββ
for lo, hi := 0, len(s)-1; lo < hi; lo, hi = lo+1, hi-1 {
s[lo], s[hi] = s[hi], s[lo]
}
fmt.Println("reversed:", s) // [4 3 99 2 1]
// ββ Filter (remove elements matching predicate) ββ
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := nums[:0] // reuse backing array, len=0
for _, n := range nums {
if n%2 == 0 {
result = append(result, n)
}
}
fmt.Println("evens:", result) // [2 4 6 8 10]
// ββ Deduplication (sorted input) ββ
sorted := []int{1, 1, 2, 3, 3, 3, 4, 5, 5}
deduped := sorted[:1]
for _, v := range sorted[1:] {
if v != deduped[len(deduped)-1] {
deduped = append(deduped, v)
}
}
fmt.Println("deduped:", deduped) // [1 2 3 4 5]
// ββ Stack using a slice ββ
var stack []int
stack = append(stack, 10) // push
stack = append(stack, 20)
stack = append(stack, 30)
top := stack[len(stack)-1] // peek
stack = stack[:len(stack)-1] // pop
fmt.Printf("popped=%d stack=%v\n", top, stack)
// ββ Queue using slices ββ
var queue []int
queue = append(queue, 1) // enqueue
queue = append(queue, 2)
queue = append(queue, 3)
front := queue[0] // front
queue = queue[1:] // dequeue
fmt.Printf("dequeued=%d queue=%v\n", front, queue)
}π€ Interview Questions
A slice header contains three fields: (1) ptr: a pointer to the start of the slice's window into the underlying array; (2) len: the number of elements accessible through this slice (bounds are [0, len)); (3) cap: the total number of elements from ptr to the end of the backing array. This determines how much append can grow without allocating.
The header is 24 bytes on 64-bit systems. When you pass a slice to a function, the 24-byte header is copied, but the underlying array is NOT copied β both the original and the function parameter point to the same memory.
Why interviewers ask: Understanding the header explains why slices are cheap to pass (just 24 bytes), why sub-slices share memory (same array pointer), and why append must return a value (it may create a new header with a new pointer).
When append would push len beyond cap, the runtime: (1) allocates a new, larger backing array; (2) copies all existing elements to the new array; (3) adds the new element(s); (4) returns a new slice header pointing to the new array. The old array is abandoned and eventually GC'd.
Time complexity: O(n) for the occasional growth (copy all n elements), but O(1) amortised because the array doubles in size each time. With geometric growth, you perform O(n) total copies for n appends, giving O(1) amortised per append.
The implication: you must always capture the return value of append. Discarding it is a bug β you lose all new elements and any capacity changes.
nil slice: declared with var s []int or left at zero value. Its ptr=nil, len=0, cap=0. Comparing to nil returns true.
Empty slice: []int{} or make([]int, 0). Its ptrβ nil (points to zero-size allocation), len=0, cap=0. Comparing to nil returns false.
They behave identically for: len(), cap(), range loops, and append(). But they differ in: JSON encoding (nil β "null", empty β "[]") and nil checks. Production code: prefer nil slices when a slice hasn't been populated yet; use empty slices only when you need non-null JSON output.
π§ͺ Quiz
a := make([]int, 8); s := a[2:5], what is len(s) and cap(s)?append(s, newElem) (discarding return value)?s2 := s1; s1 = append(s1, 100) // exceeds cap, modifying s1[0] affects s2?json.Marshal([]int(nil)) produces:π Cheat Sheet
ptr + len + cap (24 bytes on 64-bit)make([]T, len, cap)s = append(s, elem) // must assign!n := copy(dst, src) // returns counts[low:high] // shares backing array!s[low:high:max] // limits capacitys = append(s[:i], s[i+1:]...)s[i]=s[len(s)-1]; s=s[:len(s)-1]s := make([]T, 0, expectedLen)~doubles until 1024, then 1.25Γvar s []T β nil, len=0, cap=0dst := make([]T, len(src)); copy(dst, src)