Skip to Content
Course content

12: Slice Internals: Length and Capacity

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I once spent an entire afternoon debugging a "ghost" bug with a junior dev who was writing a high-performance log parser. He was slicing a massive byte buffer into smaller chunks to process different log entries. He noticed that when he modified a specific chunk, the data in a completely different part of the program—which he thought was a separate slice—was also changing. He was convinced there was a memory leak or some weird pointer magic happening. In reality, he had just fallen into the classic trap of not understanding how length and capacity actually work in Go. He thought he was creating copies of the data, but he was actually just moving a window over the same underlying array.

The Slice Header: What's Actually Under the Hood

To get why that bug happened, you have to stop thinking of a slice as "a dynamic array" and start thinking of it as a header. When you declare a slice, Go creates a small struct behind the scenes that contains three things: a pointer to an underlying array, a length, and a capacity. That's it.

// This is conceptually what a slice looks like internally
type sliceHeader struct {
    Data uintptr // Pointer to the underlying array
    Len  int     // Current number of elements
    Cap  int     // Total capacity of the underlying array from the start of the slice
}

The length is what you see when you call len(); it's the number of elements the slice currently holds. The capacity, which you get from cap(), is the total number of elements the underlying array can hold, starting from the first element of that slice. This distinction is critical because it determines whether adding a new element is a cheap operation or a heavy one.

The Dance Between Length and Capacity

Think of the capacity as the "room to grow." If you create a slice using make([]int, 3, 5), you're telling Go: "Give me a slice with 3 elements, but allocate an underlying array big enough to hold 5."

s := make([]int, 3, 5)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 5

// We can grow the length up to the capacity without allocating new memory
s = s[:5] 
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 5

Now, here is where the "ghost bug" usually happens. If you take a slice of an existing slice, the new slice shares the same underlying array. If you slice from the middle, your length decreases, but your capacity is still calculated from the start of the slice to the end of the original array. I've seen countless developers assume that s2 := s1[1:3] creates a new array. It doesn't. It just creates a new header pointing one element further into the same array. If you change s2[0], you are changing s1[1].

When Append Forces a Migration

The append() function is where the magic (and the performance cost) happens. When you append an element to a slice, Go checks if the current length is less than the capacity. If it is, Go just increments the length and writes the value into the existing underlying array. Fast and efficient.

But what happens when len == cap? Go can't just grow an array in place because the memory around it might be occupied by other data. Instead, Go performs a "migration." It allocates a new, larger array (usually double the size for smaller slices), copies all the existing elements over, and then updates the slice header to point to this new location.

s := make([]int, 2, 2) // Length 2, Capacity 2
s = append(s, 3)         // Capacity is full! 
// Go allocates a new array (likely cap 4), copies [0, 0], adds 3.
// 's' now points to a completely different memory location.

This is a vital detail. If you pass a slice to a function and that function appends to it, the function might trigger a migration. Since the slice header is passed by value, the original caller still has the old header pointing to the old array, while the function is now working with a new array. If you've ever wondered why your slice didn't "update" after being passed into a function, this is usually the culprit.




📋 Practical Task

Exercise: Fixing the Shared Buffer Corruption

You are reviewing code for a telemetry system. The developer tried to implement a "buffer splitting" logic to separate a header from a payload, but they've introduced a bug where modifying the payload accidentally corrupts the header because they share the same underlying array.

Your Goal: Modify the splitBuffer function so that the payload slice has its own independent underlying array, ensuring that changes to the payload do not affect the original buffer or the header.

package main

import "fmt"

func splitBuffer(buffer []byte) ([]byte, []byte) {
	// BUG: This creates a slice that points to the same underlying array as buffer
	header := buffer[:2]
	payload := buffer[2:]
	
	return header, payload
}

func main() {
	// A sample buffer: [Header1, Header2, Data1, Data2]
	buffer := []byte{0xAA, 0xBB, 0x01, 0x02}
	
	header, payload := splitBuffer(buffer)
	
	// The developer wants to normalize the payload by zeroing it out
	// but this currently changes the original 'buffer' and 'header' too!
	for i := range payload {
		payload[i] = 0x00
	}
	
	fmt.Printf("Buffer: %x\n", buffer) 
	fmt.Printf("Header: %x\n", header)
	fmt.Printf("Payload: %x\n", payload)
	
	// EXPECTED OUTPUT:
	// Buffer: aabb0102 (Original should remain untouched)
	// Header: aabb
	// Payload: 0000
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.