Alright, let’s pull back the curtain on what a slice actually is. Because if you think it’s just a list of values, you’re in for a rude awakening the first time you modify a slice and some other, seemingly unrelated slice magically changes too. That’s not a bug; it’s you not understanding the slice header.

A slice isn’t the data itself. It’s a glorified, three-field data structure that describes a contiguous section of an underlying array. I like to call this data structure the slice header. It’s the manager, not the worker. It contains:

  1. A pointer: The memory address of the first element of the slice in the underlying array. This is the root of all your aliasing “problems.”
  2. A length (len): The number of elements the slice can currently access. You can’t access beyond this. Trying to is what causes a panic, and rightly so.
  3. A capacity (cap): The total number of elements available in the underlying array, starting from the pointer. This is your runway for growth.

When you pass a slice to a function, you’re not passing the whole array; you’re passing this little header struct (a pointer, an int, and another int). It’s incredibly cheap to pass around, which is a huge win. But the critical thing to remember is that the pointer inside that header points to the same array as any other slice created from it. This is the source of both immense power and immense foot-shooting potential.

The Slice Header in Action

Let’s make this concrete. Here’s what it looks like when you create a slice via a slice literal. The Go runtime secretly creates an array for you and then makes a slice header that points to it.

// This creates an array of 5 integers [0:0:0:0:0] and then
// a slice header describing the whole thing.
mySlice := []int{10, 20, 30, 40, 50}

// Let's break that header down:
fmt.Printf("Pointer: %p, Length: %d, Capacity: %d\n",
    mySlice, // Yes, printing the slice variable itself shows the address its pointer points to. Neat, right?
    len(mySlice),
    cap(mySlice))
// Example output: Pointer: 0xc000102030, Length: 5, Capacity: 5

Now, let’s create a new slice from this one. This is where the pointer-copying magic happens.

// I'm not creating a new array. I'm creating a NEW SLICE HEADER.
// Its pointer field is set to point to `mySlice[1]` (i.e., the element '20').
// Its length is 2, and its capacity is 4 (because from element '20' to the end of the array is 4 spots).
newSlice := mySlice[1:3] // [20, 30]

fmt.Printf("Pointer: %p, Length: %d, Capacity: %d\n", newSlice, len(newSlice), cap(newSlice))
// Example output: Pointer: 0xc000102038, Length: 2, Capacity: 4
// Notice the pointer address is 8 bytes higher? That's one int64 (8 bytes) over from the original.

Both slice headers now point into the same underlying array. Let’s prove it.

// Let's change an element via the new slice.
newSlice[0] = 999 // This changes the element at its index 0, which is the underlying array's index 1.

// Now look at the original slice. Its index 1 is also changed!
fmt.Println(mySlice)  // [10 999 30 40 50]
fmt.Println(newSlice) // [999 30]

This isn’t a bug. It’s a feature. A very dangerous one if you’re not aware of it. It allows you to have multiple “views” of the same data without costly copying. But if you were to, say, pass a slice of a massive array to a function to process just a part of it, you’d be right to worry that the function might mess with the original data. Because it can. The pointer gives it direct access.

The Capacity Field and Its Purpose

Capacity exists for one reason: efficiency. When you append to a slice (which we’ll cover next), if there’s enough capacity in the underlying array (len < cap), the Go runtime simply slaps the new value into the next available slot and increments the length in your slice header. No new allocation, no copying. It’s incredibly fast.

This is why knowing the difference between len and cap is non-negotiable. Look at our newSlice from above:

fmt.Println(len(newSlice)) // 2
fmt.Println(cap(newSlice)) // 4

It has a runway. We can append to it without forcing a new allocation, as long as we don’t exceed its capacity.

// We have 2 free slots (cap - len = 2), so we can append twice without a new array.
newSlice = append(newSlice, 60, 70)
fmt.Println(newSlice) // [999 30 60 70]
// What does the underlying array look like now?
fmt.Println(mySlice) // [10 999 30 60 70] ...wait, what happened to the 50?

Ah, see! We overwrote it. mySlice’s length is still 5, so its last element is now 70 (which we appended) instead of 50. The original 50 is still in the array’s memory, but no slice header has a length long enough to see it anymore. It’s a ghost value. This is the kind of spooky action at a distance that drives beginners mad. The takeaway: Multiple slices can share an array, and append can mutate the shared array in ways that affect other slices. The solution to avoiding this chaos is often the three-index slice, which is our next stop.