Slice Type and Structure

Understand the internal structure of a Go slice, the three-field descriptor, and the difference between a nil slice and an empty slice

A slice in Go is not a fixed-length collection like an array. It is a lightweight, flexible view into a segment of an underlying array. The slice itself is just a small data structure that tells the runtime which portion of the array you’re working with and how much room you have left. That data structure — often called the slice header — is the heart of how slices work. Once you understand it, every other slice behavior (reslicing, appending, copying) becomes predictable.

What a Slice Actually Is

A slice is a descriptor of a contiguous segment of an array. It is defined by three pieces of information:

  • A pointer to the first element of the segment inside the underlying array.
  • The length: the number of elements the slice currently exposes.
  • The capacity: the maximum number of elements the slice can access starting from the pointer to the end of the underlying array.

The type []int, for example, does not include a length. That’s the key difference from an array type like [5]int. A variable of type []int holds the descriptor, not the elements themselves. When you assign a slice to another variable, only the descriptor is copied — not the array data. Both variables point to the same underlying array.

Memory layout:

The three‑field header is a small struct the compiler manages behind the scenes. You never manipulate it directly, but len and cap give you two of its fields. The pointer is invisible at the language level.

The Slice Descriptor in Practice

Consider a slice created from an array:

arr := [6]int{10, 20, 30, 40, 50, 60}
s := arr[1:4] // elements at indices 1, 2, 3

The slice s has:

  • Pointer → points to arr[1] (value 20)
  • Length → 3
  • Capacity → 5 (from index 1 to the end of arr, inclusive count)

Visually, the header and array look like this (each number is an array element):

arr:  [10] [20] [30] [40] [50] [60]
              ^
              |
s:  ptr-----/  len=3  cap=5

The slice itself is three machine words — typically 24 bytes on a 64‑bit system, regardless of how large the underlying array is. That’s why passing slices to functions is cheap: you’re passing the small header, not a copy of all elements.

No pointer directly to the header:

You cannot take the address of the slice’s internal pointer with &s[0] to get the underlying array address — that works and returns the address of the first element. But the raw pointer field inside the header is not exposed. The len and cap functions are the supported way to inspect the other fields.

The three‑field structure explains several slice behaviors you’ll encounter later:

  • Taking a sub‑slice s[lo:hi] creates a new header with the same pointer (shifted by lo), a new length, and a capacity that shrinks based on the available room.
  • When you append beyond capacity, Go allocates a new, larger array and copies the data. The header’s pointer and capacity change; the original slice keeps pointing to the old array.

All of that relies on the header being independent of the array.

Nil Slice

The zero value of a slice type is nil. A nil slice has no underlying array at all — its pointer, length, and capacity are all zero.

var names []string
fmt.Println(names == nil)  // true
fmt.Println(len(names))    // 0
fmt.Println(cap(names))    // 0

A nil slice behaves like an empty slice in almost every operation:

  • You can range over it — the loop body executes zero times.
  • You can call len and cap safely.
  • You can append to it. append treats a nil slice as having length 0 and capacity 0, and allocates a new backing array.

This is a deliberate design decision: idiomatic Go code often initializes slice variables to their zero value and uses append later.

var nums []int          // nil
nums = append(nums, 7)  // perfectly fine; nums is now [7]

Nil slice is a working empty slice:

Because append handles nil gracefully, you can declare a nil slice variable at the top of a function and build it up conditionally without an extra make call. This pattern is common in functions that filter or collect results.

Empty Slice

An empty slice is a non‑nil slice with length 0. It has a valid pointer (often to a shared zero‑length allocation) and a capacity that may be 0 or some positive number. You create empty slices in two main ways:

// Using a literal
s1 := []string{}
// Using make with length 0
s2 := make([]int, 0)

Both give you a non‑nil slice with len == 0. The internal pointer is not nil; it points to a runtime‑managed empty array. Capacity is 0 for the literal and 0 (or whatever was provided) for make.

s := []string{}
fmt.Println(s == nil)   // false
fmt.Println(len(s))     // 0

Operationally, an empty slice behaves exactly like a nil slice: you can append to it, range over it (zero iterations), and pass it around. The only places the distinction matters are when you compare to nil directly or when the slice appears in certain encodings.

JSON marshalling difference:

A nil slice marshals to JSON as null, while a non‑nil empty slice ([]string{} or make([]string,0)) marshals to []. If your API contract expects an empty array rather than null, you must return a non‑nil empty slice.

Choosing Between Nil and Empty Slices

Most of the time, you don’t need to decide consciously. If a slice is built up via append and never explicitly initialized, it will be nil — and that’s fine. When you need to guarantee a non‑nil empty slice (for example, for a JSON response that should always contain an empty array), use make with length 0 or a composite literal.

Here’s a function that returns nil vs empty based on intent:

func collectIfNil() []string {
    var result []string         // nil
    return result               // marshals as null
}
func collectIfEmpty() []string {
    result := make([]string, 0) // empty, non-nil
    return result               // marshals as []
}

Beware of unintentional nil returns:

A var s []T declaration yields nil. If your function later only assigns values under a condition that never fires, the caller gets nil. For APIs, that might be okay — but if the consumer expects an empty list, you’ll get null instead. A quick make([]T, 0) at the top removes the ambiguity.

Common Misunderstandings

A beginner might think a nil slice is unusable — “I need to call make before I can do anything.” In Go, the nil slice is perfectly usable for reads, length checks, and appends. The only operations that panic are indexing into it (s[0] on a nil slice) because there is no element at index 0.

Another frequent point of confusion is the difference between an array and a slice. An array [5]int is a fixed‑size block of memory whose length is part of the type. A slice []int is a dynamic descriptor. The syntax looks similar, but they are fundamentally different types, and you cannot assign one to the other without converting.

Array vs slice in function signatures:

A function parameter [3]int only accepts a 3‑element array. A parameter []int accepts any slice, including nil. Because slices are cheap to pass, functions almost always take slices, not arrays.

Summary

The slice type in Go is built around a small three‑field descriptor: pointer, length, and capacity. This header is what makes slices lightweight, dynamic, and safe to pass around. The zero value of a slice is nil, which behaves identically to an empty slice for nearly all purposes, except for direct nil comparisons and JSON encoding. Understanding this descriptor and the nil/empty distinction gives you the mental model needed to reason about reslicing, appending, and sharing data — the topics that follow.

Next, we’ll look at creating slices from scratch and from existing arrays or slices, putting the header knowledge into action.