πŸ• Chapter 7 of 20

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.

⏱ β€”πŸ“Š Intermediate

🎯 Learning Objectives

βœ… After this chapter you will:
  • 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 copy to 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:

πŸ“Š Slice Header Layout (24 bytes on 64-bit)
s := []int{10, 20, 30, 40, 50} β€” backed by a [5]int on the heap
Slice header (stack)
s.ptr0xc000b4000β†’ to array
s.len5visible elements
s.cap5total capacity
Underlying array (heap)
10 20 30 40 50
indices: 0,1,2,3,4
go
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) β†’ "[]"
}
βœ… Output
len=5 cap=5 ptr=c000b4000 len=5 cap=5 nil: true empty: false empty2: false len(nil): 0 range nil:

πŸ—οΈ Creating Slices

go
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
}
βœ… Output
[0 0 0 0 0] len=0 cap=10 [2 3 4] len=3 cap=4 [1 2 3 4 5] [1 2 3] [3 4 5] h=[2 3] len=2 cap=3

πŸ“ˆ 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.

πŸ“Š Append: When capacity is exceeded
Before: s=[1,2,3] len=3 cap=3 β€” no room!
old array123← will be abandoned (GC)
After append(s, 4): Go allocates new larger array, copies, extends
new array1234(free)(free)← new cap (typically 2Γ—)
⚠️ s now points to a DIFFERENT array than before!
go
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))
}
βœ… Output (first part)
[1 2 3 4 5 6 7 8 9 10] len=1 cap=1 len=2 cap=2 len=3 cap=4 len=4 cap=4 len=5 cap=8 len=6 cap=8 len=7 cap=8 len=8 cap=8 len=9 cap=16 ... original: [1 2 3] correct: [1 2 3 4] len: 1000000

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).

⚠️ Memory Sharing Pitfalls

go
package main

import "fmt"

func main() {
    // Two slices sharing the same backing array
    original := []int{1, 2, 3, 4, 5}
    shared := original[1:4]  // [2, 3, 4] β€” shares original's array!

    shared[0] = 99
    fmt.Println("original:", original)  // [1 99 3 4 5] β€” CHANGED!

    // Append within capacity β€” also shares!
    a := make([]int, 3, 5)  // len=3, cap=5
    a[0], a[1], a[2] = 1, 2, 3
    b := a[:5:5]  // extend to use full capacity
    b = append(b[:3], 4, 5)

    // Now a and b[0:3] share the same memory:
    b[0] = 99
    fmt.Println("a:", a)  // [99 2 3] β€” CHANGED via b!

    // βœ… Safe copy with copy()
    c := make([]int, len(original))
    copy(c, original)
    c[0] = 777
    fmt.Println("original unchanged:", original)  // [1 99 3 4 5]
    fmt.Println("c:", c)  // [777 99 3 4 5]

    // append beyond capacity β†’ new independent array
    d := original[1:3]  // [99 3], len=2, cap=4
    d = append(d, 100, 200, 300, 400)  // exceeds cap β€” new array
    d[0] = 42  // modifies d's NEW array, not original
    fmt.Println("original after large append:", original)  // unchanged

    // Memory leak: holding a large slice alive via small slice
    // Don't do this:
    bigData := make([]int, 1_000_000)
    // leaked := bigData[0:3]  // GC cannot collect bigData because leaked holds a ref!
    // βœ… Fix:
    small := make([]int, 3)
    copy(small, bigData)
    bigData = nil  // now bigData can be GC'd
    _ = small
}
βœ… Output
original: [1 99 3 4 5] a: [99 2 3] original unchanged: [1 99 3 4 5] c: [777 99 3 4 5] original after large append: [1 99 3 4 5]
🚨 Memory Leak Pattern

If you hold a small slice referencing a large backing array, the GC cannot collect the large array even if nothing else references it. Fix: use copy to create a truly independent slice, then let the large slice go out of scope.

🎨 Common Slice Patterns

go
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)
}
βœ… Output
after delete: [1 2 4 5] unordered delete: [1 2 5 4] after insert: [1 2 99 3 4 5] reversed: [4 3 99 2 1] evens: [2 4 6 8 10] deduped: [1 2 3 4 5] popped=30 stack=[10 20] dequeued=1 queue=[2 3]

🎀 Interview Questions

1
Explain the three fields of a slice header.
β–Ά
Detailed Answer

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).

2
What happens when append exceeds capacity? What is the time complexity?
β–Ά
Detailed Answer

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.

3
What is the difference between nil slice and empty slice? Does it matter?
β–Ά
Detailed Answer

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

🧩
Slices
5 questions
1. How many fields does a Go slice header have?
2. For a := make([]int, 8); s := a[2:5], what is len(s) and cap(s)?
3. What is wrong with: append(s, newElem) (discarding return value)?
4. After s2 := s1; s1 = append(s1, 100) // exceeds cap, modifying s1[0] affects s2?
5. json.Marshal([]int(nil)) produces:
β€”

πŸ“Œ Cheat Sheet

πŸ“Œ Slices β€” Quick Reference
Header
ptr + len + cap (24 bytes on 64-bit)
Make
make([]T, len, cap)
Append
s = append(s, elem) // must assign!
Copy
n := copy(dst, src) // returns count
Sub-slice
s[low:high] // shares backing array!
Three-index
s[low:high:max] // limits capacity
Delete (ordered)
s = append(s[:i], s[i+1:]...)
Delete (fast)
s[i]=s[len(s)-1]; s=s[:len(s)-1]
Pre-allocate
s := make([]T, 0, expectedLen)
Growth
~doubles until 1024, then 1.25Γ—
Nil slice
var s []T β†’ nil, len=0, cap=0
Independent copy
dst := make([]T, len(src)); copy(dst, src)