How Go Slices Work Internally (Slice Header)

A Go slice is a descriptor with a pointer, length, and capacity that references a portion of an underlying array.

The slice header: a window, not a box

You have a massive log file buffered in memory. You need to process the last 100 lines. Copying those lines into a new buffer wastes memory and CPU cycles. You want a handle that points to that chunk, lets you read it, and lets you write back to it if needed. Go gives you slices for exactly this. A slice is not a list. A slice is a view. It is a lightweight description of a piece of an array.

Under the hood, a slice is a small struct with three fields: a pointer to the underlying array, a length, and a capacity. The slice does not own the data. It borrows it. If the array lives, the slice lives. If the array goes away, the slice points to garbage. This design makes slices cheap to pass and fast to create, but it also means you need to understand what is happening behind the scenes to avoid subtle bugs.

The anatomy of a slice

Think of a slice as a window frame on a wall. The wall is the underlying array. The window frame defines what you can see. The frame has a position (the pointer), a width (the length), and a maximum width it can expand to without hitting the edge of the wall (the capacity).

The pointer tells the runtime where the first element of the slice lives in memory. The length tells the runtime how many elements are valid. The capacity tells the runtime how many elements are allocated starting from the pointer, including those beyond the current length. Capacity is the budget. Length is what you have spent.

When you create a slice from an array, the compiler calculates the pointer by adding an offset to the array base. It sets the length to the number of elements you requested. It sets the capacity to the remaining space in the array from that offset. The slice header is usually stored on the stack. The underlying array might be on the stack or the heap, depending on its size and lifetime.

Minimal example

Here is the simplest slice: carve a slice out of an array, inspect the header, and mutate through the view.

package main

import "fmt"

func main() {
	// Array lives on the stack. Fixed size of 5.
	arr := [5]int{10, 20, 30, 40, 50}

	// Slice points to arr[1].
	// Length is 3 (indices 1, 2, 3).
	// Capacity is 4 (indices 1 through 4).
	s := arr[1:4]

	fmt.Printf("Slice: %v\n", s)
	fmt.Printf("Len: %d, Cap: %d\n", len(s), cap(s))

	// Mutation goes through the pointer to the array.
	// This changes arr[1] directly.
	s[0] = 999

	// Array reflects the change because they share storage.
	fmt.Printf("Array: %v\n", arr)
}

The output shows the slice [20 30 40] with length 3 and capacity 4. After mutation, the array becomes [10 999 30 40 50]. The slice and array are two different views of the same memory. Modifying one modifies the other.

Slices are descriptors. They point. They don't own. Trust the pointer, respect the capacity.

What happens at runtime

When you pass a slice to a function, the slice header is copied by value. The pointer, length, and capacity are duplicated. The underlying array is not copied. Both the caller and the callee hold headers that point to the same array. This is why mutation works across function boundaries. It is also why you need to be careful: a function can silently modify data you think is local.

Consider the append built-in. append is not a method. It is a function that takes a slice and values, and returns a new slice. If the slice has enough capacity, append writes the new values into the unused space, increments the length, and returns a header pointing to the same array. If the capacity is exhausted, append allocates a new, larger array, copies the old data, writes the new values, and returns a header pointing to the new array.

This reallocation behavior is crucial. If you append to a slice inside a function and the capacity is exceeded, the function returns a slice pointing to a new array. The caller's slice still points to the old array. The caller does not see the new data. You must capture the return value of append to get the updated header.

The growth strategy for append is optimized. Go typically doubles the capacity when reallocating, up to a certain threshold. This amortizes the cost of copying, making repeated appends efficient. You don't need to pre-allocate perfectly, but pre-allocating with make when you know the size avoids unnecessary allocations.

Convention aside: append is a built-in function, not a method. You call append(s, v), not s.append(v). Built-ins in Go are always functions. This keeps the syntax uniform and avoids the overhead of method dispatch for core operations.

Realistic example

Here is a realistic pattern: a function that filters data and returns a new slice. The function pre-allocates capacity to avoid reallocation, processes the input, and returns the result.

package main

import "fmt"

// FilterEvens returns a slice of even numbers from the input.
// It returns a new slice header pointing to a new array.
func FilterEvens(data []int) []int {
	// Pre-allocate capacity to avoid reallocation during append.
	// Worst case is all elements are even.
	result := make([]int, 0, len(data))

	for _, v := range data {
		if v%2 == 0 {
			// Append grows the result slice.
			// Capacity was pre-allocated, so no reallocation occurs.
			result = append(result, v)
		}
	}

	return result
}

func main() {
	// Slice literal creates an array and a slice header.
	nums := []int{1, 2, 3, 4, 5, 6}

	// Pass slice by value. Header is copied. Array is shared.
	evens := FilterEvens(nums)

	fmt.Printf("Original: %v\n", nums)
	fmt.Printf("Evens: %v\n", evens)
}

The function creates a new backing array for the result. The caller's slice is untouched. This is the safe pattern when you need to transform data without side effects. If you wanted to modify in place, you would return the modified slice and document that the input is mutated.

Append returns a new slice. Always capture the result. The old slice is a ghost.

Pitfalls and runtime panics

Slices are simple, but the shared memory model introduces pitfalls. The most common is the bounds panic. If you index a slice beyond its length, the runtime panics.

The runtime panics with panic: runtime error: index out of range [i] with length len if you access s[i] where i >= len(s). This check happens at runtime, not compile time, because slice lengths are dynamic. Always check length before indexing, or use the three-index slice form to limit capacity.

Another pitfall is aliasing during append. If you pass a slice to a function, and the function appends to it, the function might reallocate. If the function returns the new slice, the caller gets the new data. If the caller ignores the return value, the caller keeps the old slice. Worse, if the caller and callee both hold slices to the same array, and one appends beyond the other's length, the other slice might see garbage or unexpected values.

Example: a := []int{1, 2, 3}. b := a[:2]. a = append(a, 4). Now b still has length 2, but the underlying array has changed. If a reallocated, b points to the old array. If a did not reallocate, b points to the same array, and b[2] would now be 4 if you expanded b. This is subtle. Use copy to break aliasing when needed.

The copy built-in moves data between slices. copy(dst, src) copies the minimum of the two lengths. It is safe and efficient. Use copy when you need to duplicate data without sharing the backing array.

Convention aside: make initializes slices, maps, and channels. new allocates zeroed memory and returns a pointer. For slices, make([]int, len, cap) is the standard. new([]int) returns a *[]int, which is a pointer to a slice header. This is almost never what you want. The compiler rejects make(int, 10) with cannot make type int. Use make([]int, 10) for a slice.

Nil slices vs empty slices is another distinction. var s []int is a nil slice. s := []int{} is an empty slice. Both have length 0. Nil slices marshal to null in JSON. Empty slices marshal to []. Use nil for "no data" and empty for "empty list". This convention helps APIs distinguish between missing and empty.

The three-index slice form s[low:high:max] lets you limit capacity. max sets the capacity to max - low. This is useful for passing a slice to a function and preventing it from seeing or modifying data beyond a certain point. It is a safety tool for library code.

The runtime panics on bounds. Check length before indexing. Or use the three-index form to be safe.

Decision matrix

Use an array when the size is fixed and known at compile time, and you need the value to be fully self-contained. Arrays are values. Passing an array copies all elements. Use arrays for small, fixed-size data like coordinates or color channels.

Use a slice when you need a dynamic view of data, want to pass data without copying, or need to grow the collection. Slices are the default collection type in Go. Use slices for lists, buffers, and function arguments.

Use make when you need to allocate a slice with a specific length or capacity. make([]T, len, cap) allocates the backing array and initializes the header. Use make when you know the size upfront to avoid reallocation.

Use append when you need to grow a slice, and always capture the returned slice header. append handles capacity management and reallocation. Use append for building lists dynamically.

Use copy when you need to duplicate data between slices or break aliasing. copy is safe and efficient. Use copy when you need independent copies of data.

Use a nil slice when you want to represent "no data" or an uninitialized state. Nil slices marshal to null in JSON. Use nil for optional lists.

Use an empty slice when you want to represent an empty list. Empty slices marshal to [] in JSON. Use empty slices for results that are explicitly empty.

Arrays for fixed math. Slices for everything else.

Where to go next