Slices are the most used data structure in Go, and also one of the most misunderstood. Many bugs in production Go code (surprising mutations, silent memory leaks, mysterious allocations) come from not knowing what a slice actually is. This article explains slices from the ground up: the internal header, how data is stored and retrieved, how append grows memory, and where slices live: stack or heap.

1. Start With Arrays

An array in Go is a fixed-size, contiguous block of memory, and its size is part of its type. [3]int and [4]int are different types. Arrays are values: assigning or passing one copies every element.

a := [3]int{1, 2, 3} b := a // full copy of 3 ints b[0] = 99 fmt.Println(a[0], b[0]) // 1 99 

Copying large arrays everywhere is expensive and inflexible. Slices solve that.

2. What Is a Slice? The Slice Header

Analogy: think of the backing array as a book, and the slice as a bookmark with a note saying: start here, I am reading this many pages, and I can read up to this many pages before the book ends. Many bookmarks can point into the same book.

A slice is a small struct (the slice header). In the Go runtime it looks like this:

type slice struct { array unsafe.Pointer // pointer to the first element of the slice len int // number of elements accessible cap int // elements available from array pointer to end of backing array } 

On a 64-bit machine the header is exactly 24 bytes (8 + 8 + 8), no matter how many elements the slice has.

slice header (24 bytes) backing array +------------+ +----+----+----+----+----+ | ptr |------------------>| 10 | 20 | 30 | 40 | 50 | | len = 3 | +----+----+----+----+----+ | cap = 5 | [0] [1] [2] [3] [4] +------------+ 

Key insight: the header and the backing array are two separate things, and they can live in different places in memory. The header is a plain value (it can sit on the stack, in a struct, inside another slice). The backing array is where your data lives.

3. Creating Slices

// 1. Literal: allocates backing array and header s1 := []int{1, 2, 3} // 2. make(type, len, cap) s2 := make([]int, 3) // len 3, cap 3, zero-filled s3 := make([]int, 0, 10) // len 0, cap 10, room to grow without realloc // 3. Slicing an array or another slice (no copy!) arr := [5]int{10, 20, 30, 40, 50} s4 := arr[1:4] // [20 30 40], len 3, cap 4 // 4. nil slice var s5 []int // ptr nil, len 0, cap 0 

nil slice vs empty slice

var a []int // nil slice b := []int{} // empty, non-nil c := make([]int, 0) // empty, non-nil fmt.Println(a == nil, b == nil, c == nil) // true false false fmt.Println(len(a), len(b)) // 0 0 

Both work with len, cap, append, and range. The difference shows up in == nil checks and in JSON encoding: a nil slice marshals to null, an empty slice to []. APIs that return lists to clients should usually return [].

4. Length vs Capacity

  • len: how many elements you can read or write by index.
  • cap: how many elements exist from the slice's first element to the end of the backing array.
arr := [6]int{0, 1, 2, 3, 4, 5} s := arr[1:3] fmt.Println(len(s), cap(s)) // 2 5 s = s[:5] // legal: extend len up to cap fmt.Println(s) // [1 2 3 4 5] // s[:6] would panic: slice bounds out of range 

Indexing beyond len panics, even if capacity exists. Re-slicing can reach into capacity.

5. Slicing Shares Memory

The expression s[low:high] creates a new header pointing into the same backing array. It is O(1) and copies no elements.

s := []int{1, 2, 3, 4, 5} t := s[1:3] t[0] = 100 fmt.Println(s) // [1 100 3 4 5] (s changed!) fmt.Println(&s[1] == &t[0]) // true: same memory fmt.Println(len(t), cap(t)) // 2 4 

Full slice expression: s[low:high:max]

The three-index form limits capacity, which prevents a sub-slice from overwriting or appending into the rest of the original array.

s := []int{1, 2, 3, 4, 5} t := s[1:3:3] // len 2, cap 2 t = append(t, 999) // cap exceeded: new array allocated fmt.Println(s) // [1 2 3 4 5] untouched 

6. append and How Slices Grow

append has two paths:

  1. Capacity available: the element is written into the existing backing array, and a new header with len+1 is returned. No allocation.
  2. Capacity exhausted: the runtime (growslice) allocates a new, larger backing array, copies old elements, writes the new ones, and returns a header pointing to the new array. The old array stays alive only while something still references it.

This is why you must always assign the result: s = append(s, x).

Growth strategy (Go 1.18 and later)

  • If the required length is more than double the old capacity, new capacity is the required length.
  • If old capacity is below 256, capacity doubles.
  • Above 256, capacity grows by (newcap + 3*256) / 4 repeatedly, a smooth transition from 2x towards about 1.25x.
  • The result is then rounded up to the allocator's size class, so real capacities are not always clean numbers.
var s []int prev := cap(s) for i := 0; i < 2000; i++ { s = append(s, i) if cap(s) != prev { fmt.Println(len(s), cap(s)) prev = cap(s) } } // Typical 64-bit output (varies by Go version): // 1 1, 2 2, 3 4, 5 8, 9 16, 17 32, 33 64, 65 128, // 129 256, 257 512, 513 848, 849 1280, 1281 1792, 1793 2560 

Treat the exact numbers as an implementation detail. The pattern (amortized O(1) append) is the guarantee.

The classic aliasing bug

a := make([]int, 3, 4) b := append(a, 100) // spare capacity: writes into shared array c := append(a, 200) // overwrites the same slot! fmt.Println(b[3], c[3]) // 200 200 

Both b and c share one backing array. Fix it by limiting capacity (a[:3:3]) or by copying first.

7. Passing Slices to Functions

Go is always pass-by-value. Passing a slice copies the 24-byte header, not the elements. Hence:

  • Modifying elements inside the function is visible to the caller (same backing array).
  • Changing len/cap/pointer (via append or re-slicing) is not visible, because the function has its own header copy.
func modify(s []int) { s[0] = 100 // visible to caller s = append(s, 4) // may reallocate; caller's header unchanged } func main() { s := []int{1, 2, 3} modify(s) fmt.Println(s) // [100 2 3] } 

To let a function grow a slice, either return it (s = add(s, 4), the idiomatic way) or take a pointer (*[]int).

8. Copying Slices

copy(dst, src) copies min(len(dst), len(src)) elements and returns that count. It handles overlapping memory correctly.

src := []int{1, 2, 3, 4} dst := make([]int, 2) n := copy(dst, src) fmt.Println(n, dst) // 2 [1 2] // Independent clone (Go 1.21+) clone := slices.Clone(src) 

Assigning b := a for slices copies only the header, so both still share data. Use copy or slices.Clone for a real duplicate.

9. Stack vs Heap: Where Does a Slice Live?

This is where most engineers get confused, so be precise. A slice has two parts, and each is placed independently by the compiler:

Part What it is Where it lives
Slice header 24-byte struct (ptr, len, cap) Usually the stack (as a local variable). Heap if stored in a heap object, a escaping closure, or an interface
Backing array The actual elements Stack or heap, decided by escape analysis

Escape analysis in one paragraph

The Go compiler analyzes whether a value's lifetime can be proven to end with the function that created it. If yes, it goes on the stack, which is allocated and freed by moving the stack pointer, with no garbage collector involvement. If the value might outlive the function, or the compiler cannot prove otherwise, it escapes to the heap, which is managed by the garbage collector. Stack allocation is dramatically cheaper.

When the backing array stays on the stack

  • The slice is created with make or a literal using a compile-time constant size.
  • The size is small (implicit allocations are limited to about 64 KB).
  • The slice (or its pointer) never escapes the function.

When the backing array escapes to the heap

  • The slice is returned or stored somewhere that outlives the function.
  • It is sent on a channel, stored in a global, or assigned into a heap object.
  • It is converted to an interface (for example passed to fmt.Println, in some cases).
  • It is captured by a closure that escapes (goroutines are a common case).
  • Its size is not a constant (make([]int, n)), in older compilers always heap. Newer releases (Go 1.25) can place small variable-size buffers on the stack in more cases.
  • It is too large for the stack.
  • It grows via append past capacity: growslice allocates the new array through the runtime allocator, on the heap.

See it yourself

package main func stackSlice() int { s := make([]int, 0, 8) // constant size, does not escape for i := 0; i < 8; i++ { s = append(s, i) } total := 0 for _, v := range s { total += v } return total } func heapReturn() []int { s := make([]int, 0, 8) return s // backing array must outlive the function } func heapDynamic(n int) int { s := make([]int, n) // size unknown at compile time return len(s) } func main() { _ = stackSlice() _ = heapReturn() _ = heapDynamic(10) } 

Build with the escape analysis flag:

go build -gcflags='-m' main.go 

Illustrative output:

main.go:4:11: make([]int, 0, 8) does not escape main.go:14:11: make([]int, 0, 8) escapes to heap main.go:19:11: make([]int, n) escapes to heap 

Use -gcflags='-m -m' for the reason behind each decision. Combine this with go test -bench . -benchmem to see allocations per operation. Remember: escaping is not a bug, it is a cost to measure, not to fear.

10. How Data Is Stored and Retrieved

Storing

Elements of a slice are laid out contiguously in the backing array, each taking sizeof(T) bytes. s[i] = v compiles to: check i < len, then write at address ptr + i * sizeof(T).

Retrieving

v := s[i] is the same arithmetic, plus a bounds check:

if uint(i) >= uint(len(s)) { panic(index out of range) } value = *(ptr + i * sizeof(T)) 

That is O(1), and thanks to contiguous layout it is very CPU-cache friendly. Iterating a []int is far faster than walking a linked list. The compiler can eliminate bounds checks in loops when it can prove safety (for example in for i := range s).

Garbage collection and slices

The GC tracks the backing array through any pointer into it, including a header pointing into the middle. As long as one slice references the array, the entire array stays alive. Two consequences follow.

Memory leak by tiny sub-slice:

func firstLine(data []byte) []byte { return data[:10] // keeps the WHOLE data array alive (maybe 100 MB) } // Fix: copy what you need func firstLineSafe(data []byte) []byte { return slices.Clone(data[:10]) } 

Leaking pointers after removal: when a slice holds pointers, shrinking it with s = s[:len(s)-1] leaves the old pointer in the backing array, so the GC cannot free the object. Clear it first:

s[len(s)-1] = nil s = s[:len(s)-1] // or use clear(...) on the discarded region (Go 1.21+) 

11. Peeking at the Header With unsafe

For learning (never for production logic), you can inspect the header.

s := make([]int, 3, 10) fmt.Println(unsafe.Sizeof(s)) // 24 fmt.Println(unsafe.SliceData(s)) // pointer to the first element fmt.Println(len(s), cap(s)) // 3 10 

reflect.SliceHeader is deprecated. Prefer unsafe.SliceData and unsafe.Slice.

12. Everyday Operations

Insert and delete

// Delete index i, keeping order s = append(s[:i], s[i+1:]...) // or, Go 1.21+ s = slices.Delete(s, i, i+1) // Delete index i, order does not matter (O(1)) s[i] = s[len(s)-1] s = s[:len(s)-1] // Insert v at index i s = slices.Insert(s, i, v) 

Filter in place (zero extra allocation)

kept := s[:0] for _, v := range s { if v%2 == 0 { kept = append(kept, v) } } s = kept 

Iterating with range

range evaluates the slice once. Appending inside the loop does not extend the iteration. The value variable is a copy of each element, so modify via index:

for i := range users { users[i].Active = true // modifies the real element } 

Since Go 1.22, each loop iteration has its own loop variable, which fixes the old closure-capture pitfall.

Multi-dimensional slices

grid := make([][]int, 3) for i := range grid { grid[i] = make([]int, 4) // each row is a separate allocation } 

Rows are not contiguous with each other. For performance-critical matrices, allocate one flat []int of rows*cols and index with r*cols + c.

Converting slices to arrays

s := []int{1, 2, 3, 4} arr := [4]int(s) // Go 1.20+: copies, panics if len(s) < 4 p := (*[4]int)(s) // Go 1.17+: pointer to the same memory 

The standard slices package (Go 1.21+)

slices.Sort, slices.BinarySearch, slices.Contains, slices.Index, slices.Equal, slices.Reverse, slices.Max, slices.Min, slices.Compact, slices.Clip, slices.Grow, and slices.Clone remove most of the hand-written loops.

13. Performance Best Practices

  1. Preallocate when you know the size. make([]T, 0, n) avoids repeated reallocations and copying.
  2. Use slices.Grow before a big batch of appends when the final size is estimable.
  3. Reuse buffers with s = s[:0] in hot paths (or sync.Pool) to reduce GC pressure.
  4. Clone sub-slices you plan to keep long-term out of large buffers.
  5. Clear pointer elements when shrinking slices of pointers.
  6. Use the three-index slice when handing a sub-slice to code that might append.
  7. Prefer slices of values over slices of pointers for small structs: better locality and fewer GC objects.
  8. Measure, do not guess. Use -gcflags=-m, -benchmem, and pprof to find real allocation hot spots.

14. Quick Reference

Concept Remember
Slice Header (ptr, len, cap), 24 bytes on 64-bit
Assignment b := a Copies header only; data shared
Re-slice s[i:j] O(1), shares backing array
append within cap In place, no allocation
append over cap New array on heap, old elements copied
Function argument Header copied; element writes visible, append not
nil vs empty Both len 0; differ in == nil and JSON
Header location Usually stack
Backing array location Escape analysis: stack if constant, small, non-escaping; else heap
Sub-slice leak Whole array stays alive; clone what you keep

Conclusion

A slice is a tiny, cheap-to-copy descriptor over a contiguous array. Most of its surprising behavior (shared mutations, append aliasing, memory retention) follows directly from that one fact. Learn the header, understand capacity, let escape analysis do its job, and verify with the compiler and benchmarks. Once the mental model is right, slices become one of the most predictable and fastest tools in your Go toolbox.