How Slice Internals Work

Length, Capacity, and Underlying Arrays

Go slices manage dynamic arrays by tracking current length and total capacity, automatically resizing when needed.

The clipboard and the auditorium

You are writing a parser that reads a stream of JSON objects. You create a slice to hold the parsed records. You call append a thousand times. The program runs fine. Then you slice it to pass a subset to another function: subset := records[10:20]. You modify subset, and suddenly the original records slice shows corrupted data. Or your memory usage refuses to drop, even though you deleted the original variable. The problem is not your logic. It is the invisible contract between a slice and the array it points to.

A Go slice is not a container. It is a view. Under the hood, the runtime stores three pieces of information: a pointer to the first element, a length, and a capacity. The pointer points to a contiguous block of memory called an array. The length tells the program how many elements are currently active. The capacity tells the program how many slots exist in the allocated block. Think of a slice as a theater usher's clipboard. The clipboard lists which seats are sold and points to the actual auditorium. The auditorium has a fixed number of seats. If you sell out, you cannot magically add more chairs to the current room. You have to book a larger auditorium, move everyone over, and update the clipboard.

A slice is a window, not a warehouse.

The three-word header

In Go source code, you never see the slice header directly. The language hides it behind a clean syntax, but the runtime treats it like a small struct containing a pointer, an integer for length, and an integer for capacity. When you pass a slice to a function, you are passing this three-word header by value. The pointer, length, and capacity are copied onto the stack. The underlying array is not copied.

// InitializeSlice demonstrates the difference between length and capacity.
func InitializeSlice() []int {
    // make allocates the underlying array on the heap.
    // len is 0, cap is 5. The pointer points to the first slot.
    s := make([]int, 0, 5)

    // append adds elements. It updates the length but stays within capacity.
    // No new memory is allocated yet.
    s = append(s, 1, 2, 3)

    // The slice header now points to the same array.
    // len is 3, cap is still 5.
    return s
}

The len and cap functions are built-ins that read the header. They run in constant time. They do not traverse the data. When you print a slice with fmt.Println, the formatter reads the length to know how many elements to display. It ignores the capacity entirely. This separation is intentional. Length represents your logical data. Capacity represents your memory reservation.

Go developers rarely pre-allocate massive slices unless profiling proves it is necessary. The standard library and community codebases prefer var s []T and let append handle growth. The runtime is highly optimized for this pattern. Pre-allocating with make([]T, 0, expectedSize) is fine when you know the size upfront, like reading a fixed number of records from a database. Guessing the size and over-allocating wastes heap space. Under-allocating triggers a few extra copies, which is usually cheaper than holding onto dead memory.

What happens when you run out of room

The append built-in checks the current length against the capacity. If there is room, it places the new element at the next index and increments the length. If the slice is full, append triggers a reallocation. The runtime calculates a new capacity, allocates a fresh array on the heap, copies every element from the old array to the new one, and updates the slice header to point to the new memory. The old array becomes unreachable and waits for the garbage collector.

The growth strategy is not arbitrary. For small slices, the runtime doubles the capacity. Once the slice grows past a certain threshold, the runtime switches to a 1.25 multiplier. This prevents wasting memory on massive allocations while keeping the number of reallocations logarithmic. You never pay for a copy on every single append. You pay for it only when the slice grows, and the cost is amortized across many cheap appends.

// GrowSlice shows how append handles capacity exhaustion.
func GrowSlice() []int {
    // Start with a tight capacity of 2.
    s := make([]int, 0, 2)

    // These two appends fit without reallocation.
    s = append(s, 10, 20)

    // This append exceeds capacity.
    // The runtime allocates a larger array, copies 10 and 20, then adds 30.
    // The old array is abandoned.
    s = append(s, 30)

    return s
}

Amortized growth means you pay for copies rarely, not never.

The shared array trap

The most common slice mistake is assuming a slice owns its data. When you create a subslice with s[low:high], you are not copying the elements. You are creating a new header that points to the same underlying array. If you modify the subslice, you modify the original data. This is fast, but it causes subtle bugs when the original slice goes out of scope. The runtime cannot free the underlying array because the subslice still holds a pointer to it. The memory stays allocated until the subslice is also garbage collected.

// ExtractAndModify demonstrates the shared memory pitfall.
func ExtractAndModify(data []int) []int {
    // Create a subslice pointing to the middle of the original array.
    // No data is copied. Only the header is created.
    subset := data[2:5]

    // Modifying subset changes the underlying array.
    // The original data slice sees the change immediately.
    subset[0] = 999

    // Returning subset keeps the entire underlying array alive.
    // Even if data goes out of scope, the memory is not freed.
    return subset
}

If you try to access an index beyond the length, the runtime panics with runtime error: slice bounds out of range. The compiler will not catch this. It only checks types and syntax. If you pass a slice where an array is expected, the compiler rejects it with cannot use s (variable of type []int) as [5]int value in argument. Slices and arrays are distinct types. You can convert a slice to an array only if you know the exact length at compile time, which defeats the purpose of dynamic sizing.

Another trap is the s = s[:0] pattern. If you reuse a large slice by resetting its length to zero, the capacity remains high. The underlying array stays in memory. This is intentional for performance. If you want to release the memory, you must assign nil to the slice or create a new one. The garbage collector only reclaims memory when no live pointer references it. A slice with length zero but high capacity still points to a large block of memory.

Subslices share memory. Treat them as aliases, not copies.

Picking the right container

Go gives you several ways to group data. The right choice depends on how you access the elements and whether the size changes.

Use a slice when you need a dynamically sized collection of homogeneous elements. Use a fixed-size array when the length is known at compile time and you want stack allocation for maximum performance. Use a map when you need key-value lookups instead of positional indexing. Use a linked list or tree when you frequently insert or delete elements in the middle of a collection. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Pick the data structure that matches your access pattern, not your fear of allocation.

Where to go next