Last Updated: May 22, 2026
A slice in Go is a small struct that points at a backing array. Once you can picture that struct, the behaviour that causes confusion (slices "sharing memory", cap differing from len, sub-slices mutating their parent) becomes clear. This chapter walks through the slice header, how slice expressions carve new views out of the same array, and what len and cap actually measure.
A slice value is three words wide. It holds a pointer to a backing array, a length, and a capacity. Everything else about slice behaviour follows from that fact.
The literal []string{"Apple", "Bread", "Cheese", "Donut"} allocates a backing array of 4 strings and creates a slice header whose pointer aims at element 0, whose length is 4, and whose capacity is also 4. The slice header itself is on the stack (or wherever cart lives); the four strings live in a separate array somewhere on the heap.
Here is the same picture as a diagram. The slice on the left is the three-word header. The boxes on the right are the backing array.
The pointer always aims at the first element the slice can see. Index 0 of a slice is the element the pointer is pointing at, index 1 is the next one in the array, and so on up to len-1. Reading or writing index i checks i < len(s) at runtime and panics with index out of range if it isn't.
Copying a slice value, whether by assignment, by passing it to a function, or by returning it, copies the three-word header. The backing array is not copied. Both copies of the header point at the same array, which is why writing through one slice is visible through the other.
bag := cart copies the header. Both headers now have a pointer to the same array, both have len = 3, both have cap = 3. The write bag[0] = "Milk" hits index 0 of the backing array, and cart sees the change because its pointer aims at the very same byte.
len(s) is the number of elements you can index right now. cap(s) is the number of elements available in the backing array starting from where the slice's pointer is. These two numbers are not the same, and the difference is what makes append cheap most of the time.
make([]string, 3, 8) allocates a backing array of 8 strings and gives back a slice header with len = 3 and cap = 8. Only the first three slots are visible through the slice; positions 3 through 7 exist in the array but are off-limits to indexing. Writing wishlist[3] panics with index out of range, even though the underlying array has room.
The room at the end is what append uses. As long as len < cap, append can drop the new value into the next free slot and bump the length by one, no allocation required. When the slice fills up, append allocates a bigger array, copies the existing elements over, and returns a slice pointing at the new array.
The pointer still aims at index 0 of the backing array. len = 3 says you can index [0], [1], and [2]. cap = 8 says the array has 5 more slots that append can grow into before reallocating.
Cost: append is amortized O(1) only while len < cap. Once the slice fills, it allocates a new array and copies every element over. Pre-sizing with make([]T, 0, n) when you know the final size up front avoids those copies.
cap(s) is sometimes equal to len(s). A slice literal like []int{1, 2, 3} produces a slice with len = 3, cap = 3. There is no slack at the end, so the next append always reallocates. A make([]T, n) call (with no third argument) does the same: len = n, cap = n.
The c form (length zero, capacity five) is the right choice when you're about to append into an empty slice and you know roughly how big it will get. Five appends fit without any reallocation. Here we're just using it to demonstrate that cap and len are independent knobs.
s[low:high]A slice expression carves out a new view onto the same backing array. The syntax s[low:high] produces a slice that starts at index low (inclusive) and ends at index high (exclusive). The new slice points at the same array, just at a different offset.
products[1:4] builds a new slice header. Its pointer aims at products[1], so element 0 of middle is "Bread". Its length is 4 - 1 = 3, the number of elements in the slice. Its capacity is 5 - 1 = 4, which is the number of array slots from products[1] to the end of the backing array.
The capacity of a sub-slice is not high - low. It is cap(s) - low, the distance from the new starting position to the end of the original backing array. That extra slack is why append to a sub-slice can overwrite data the parent slice still uses.
Both ends of the slice expression are optional. s[:3] means s[0:3], and s[2:] means s[2:len(s)]. Writing s[:] is s[0:len(s)], which is a no-op that produces a fresh header pointing at the same range.
The bounds have to satisfy 0 <= low <= high <= cap(s). Note that high is checked against cap(s), not len(s). That's how you can extend a slice past its current length, as long as the backing array has room. s[:cap(s)] re-slices to use the full backing array. Going one past cap(s) panics with slice bounds out of range.
items[:5] is legal because cap(items) == 5. The extra two slots were already in the backing array, sitting at their zero value, so bigger sees 0 0 at the end. This trick is useful when you want to reuse a pre-allocated buffer without calling append.
s[low:high:max]Go has a third form of slice expression that controls capacity explicitly: s[low:high:max]. The new slice has len = high - low and cap = max - low. The element range you can read or write is still [low, high). The max argument doesn't change the element range, only the capacity, which decides how far append can grow the sub-slice before it has to allocate.
products[1:4:4] reads as "start at index 1, end at index 4, and cap the capacity at index 4 too." The result has zero spare capacity, so the next append to threeForm allocates a fresh backing array. The old array is now safe from the sub-slice; nothing the sub-slice does can write past index 3 of the original.
This matters when you hand a sub-slice to a function or a goroutine and you don't want it to be able to scribble over the parent's data. Without the 3-index form, an append that fits in the parent's spare capacity overwrites the parent without warning.
preview has len = 2 and cap = 5 (because cap(cart) = 5 and low = 0). The append fits in spare capacity, so it writes "INTRUDER" to index 2 of the shared backing array. cart[2] was "Cheese". It isn't anymore.
The fix is to cap the sub-slice's capacity at the same index as its length using the 3-index form. That forces the next append to allocate a new backing array.
Now cart is untouched. The append had nowhere to grow into the original array, so it allocated a fresh array of strings and copied the two existing elements into it. preview is now independent.
Cost: The 3-index form forces an allocation on the first append past max, which costs one heap allocation plus a copy. The payoff is that you stop sharing memory with the parent, so reads and writes through the parent can't be clobbered.
The bounds for the 3-index form have to satisfy 0 <= low <= high <= max <= cap(s). max must be at least high, so you can never produce a slice whose capacity is less than its length. Going past cap(s) panics, same as the 2-index form.
Once you have one slice, you can produce many sub-slices that all point into the same backing array. Each sub-slice has its own header (pointer, length, capacity), but the elements they show overlap with the parent and with each other.
All three slices (products, front, back) point at the same backing array. front[2] is index 2 of the array. back[0] is also index 2 of the array, because back's pointer starts at array index 2. Writing through front[2] updates that single array slot, and every slice that includes it sees the change.
The boxes at the top are the slice headers, and the arrows show where each pointer aims.
Index 2 is highlighted because both front and back can see it. front sees it as front[2]. back sees it as back[0]. Whichever slice writes to that slot, the other one observes the new value on the next read.
The same thing happens when you pass a slice into a function. The function gets a copy of the header, but the pointer still aims at the caller's backing array, so writes through the parameter mutate the caller's data.
clearFirst received a copy of the slice header but the same pointer. Writing items[0] = "" updated the shared backing array, and main sees the change after the call returns. This is why slice parameters behave like references even though Go passed the header by value.
Holding onto a small sub-slice of a large array keeps the whole backing array alive in memory. That's a real source of memory leaks in long-running Go programs. The mechanism is the same as everything in this section: the sub-slice's pointer still aims at the original backing array, so the garbage collector can't reclaim it.
Tracking capacity separately from length lets append grow without reallocating most of the time. As long as len < cap, append just writes the new element into the next free slot and bumps the length by one. When the slice fills up, Go allocates a new, bigger backing array, copies the existing elements over, and returns a slice that points at the new array. The mechanics that matter for this chapter are simpler.
After a growing append, the new slice doesn't share a backing array with the old one anymore. Any other slices that pointed at the old array are now orphaned: they still see the old values, and writes through them don't reach the new array.
Before the second append, original and view shared a backing array. The append(original, 4) filled the slice past its capacity of 3, so Go allocated a bigger array, copied [1 2 3] into it, wrote 4 at index 3, and original now points at the new array. view still aims at the old one. Writing original[0] = 99 hits the new array, so view doesn't see it.
This is the rule that makes append confusing: the returned slice may or may not share memory with the input slice. It depends entirely on whether there was spare capacity. Always reassign the result, and never assume that two slices stay linked after an append.
len and cap TogetherLooking at len and cap side by side is the quickest way to figure out what a slice is doing. Print them whenever a sub-slice is behaving in a way you didn't expect.
first2 has the full capacity of 10 because it starts at index 0 of the same backing array. last2 starts at index 2, so its capacity is 10 - 2 = 8. bounded is the 3-index form cart[2:4:4], which clamps the capacity to 4 - 2 = 2, giving it no spare room. Three slices, three different cap values, all looking at the same array.
A common debugging move is to drop fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) next to any line that surprises you. If cap(s) is bigger than you expected, you're sharing a backing array with something else, and the next append might not allocate. If cap(s) == len(s), the next append definitely allocates.