Slice Length and Capacity
How len and cap reflect a slice's underlying memory, what happens when a slice outgrows its capacity, and why pre-sizing matters for performance.
A slice’s length and capacity are not just two numbers you print when debugging. They are the mechanism that allows slices to grow dynamically while staying backed by fixed-size arrays. Understanding how they interact is essential for writing efficient Go code and avoiding runtime panics.
What Length and Capacity Mean
The length of a slice is the number of elements it currently contains. You access it with the built-in function len(s).
The capacity of a slice is the number of elements the underlying array can hold starting from the slice’s first element. You access it with cap(s).
These two values are independent, but they are always related:
len(s) <= cap(s)for every non-nil slice.- A
nilslice has both length and capacity equal to0.
Mental model for beginners:
Think of a slice as a movable window over a long row of lockers (the array). The length is how many lockers you have opened and are using right now. The capacity is how many lockers are available from your current position to the end of the row — you can open more without having to build new lockers, but you can’t go past the last locker.
The Slice Header and the Underlying Array
Every slice is a lightweight data structure (a “slice header”) containing three pieces of information:
- A pointer to an element of an underlying array.
- The length.
- The capacity.
When you create a slice with make([]int, 3, 5), the compiler allocates a hidden array of size 5 and sets the slice header’s pointer to the start of that array, length to 3, and capacity to 5.
This header is what gets passed around by value when you assign slices or pass them to functions. Copying a slice copies the pointer, length, and capacity — not the underlying array. Two slices can therefore share the same backing store, with different lengths or starting offsets.
Inspecting Length and Capacity
Use len() and cap() directly:
s := make([]int, 3, 5)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 5
If you slice an existing slice, the new slice’s length and capacity follow clear rules derived from the underlying array.
parent := []int{10, 20, 30, 40, 50}
child := parent[1:3] // indices 1 and 2
fmt.Println(child) // [20 30]
fmt.Println(len(child)) // 2
fmt.Println(cap(child)) // 4 (from index 1 to end of parent’s array)
The capacity of the new slice is the number of elements from the start of the slice to the end of the underlying array. Changing child through index or append (within its capacity) will alter the same memory that parent sees — a frequent source of surprises.
How Capacity is Set When You Create a Slice
Different creation paths assign capacity differently:
- Slice literal:
s := []int{1, 2, 3}creates a new underlying array of exactly 3 elements. Length = 3, capacity = 3. make([]T, length): allocates an array of sizelength. Capacity equals length.make([]T, length, capacity): allocates an array of sizecapacity. The slice has the given length (which must be ≤ capacity). The remainingcapacity - lengthelements are “spare” room for future growth without reallocation.- Slicing an array or slice: capacity is the distance from the slice’s first element to the end of the underlying array.
Use make with an explicit capacity when the final size is predictable:
Pre-allocating spare capacity with make([]T, 0, knownSize) is one of the simplest performance wins in Go. It avoids multiple allocations and data copies when you later append elements. The append function will use the spare room instead of repeatedly creating new backing arrays.
Re-slicing Changes Length, Not Capacity
The expression s[low:high] creates a new slice with the same underlying array, but with length high - low. The capacity becomes cap(s) - low. You can extend a slice’s length by re-slicing up to its capacity — but not beyond.
s := []int{2, 3, 5, 7, 11, 13}
fmt.Println(len(s), cap(s)) // 6 6
s = s[:0] // zero length, capacity still 6
fmt.Println(len(s), cap(s)) // 0 6
s = s[:4] // extend to the first 4 elements
fmt.Println(len(s), cap(s)) // 4 6
fmt.Println(s) // [2 3 5 7]
s = s[2:] // drop first two
fmt.Println(len(s), cap(s)) // 2 4
Re-slicing beyond capacity panics:
Trying to extend a slice beyond cap(s) — for example, s = s[: cap(s)+1] — causes a runtime panic: runtime error: slice bounds out of range. The slice window cannot see past the end of its underlying array.
A common realisation at this point is that you can have a slice whose length is smaller than its capacity, and you can index only within the length. Accessing s[i] where i is between len(s) and cap(s)-1 compiles fine but panics at runtime because those elements are not “visible” through the slice until you extend the length by re-slicing.
How a Slice Grows Beyond Its Capacity
The append built‑in adds elements to the end of a slice. If the new elements fit within the existing capacity, append writes them into the underlying array and returns a slice with an increased length — no allocation happens. If the capacity is too small, append allocates a new, larger underlying array, copies the original elements over, and then appends the new elements. The old array becomes eligible for garbage collection.
The growth algorithm multiplies the capacity, typically doubling it for small slices and increasing by a smaller factor for very large ones. You can observe this by printing the capacity after each append:
var s []int
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
Output:
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
Each time the capacity is exhausted, it jumps to a larger number. Crucially, the pointer inside the slice header changes whenever a new backing array is allocated. Any other slice that shared the old array still points to the old memory and will not see the new elements.
The result of append may point to a different backing array:
Because append can change the underlying array, you must always assign the returned slice back to the same variable (or another variable) if you intend to keep the grown version. Forgetting to do so is one of the most frequent mistakes with slices: append(s, x) without s = append(s, x) leaves s pointing to the old, potentially stale memory.
Pre-allocating Capacity Avoids Repeated Copying
When you know in advance how many elements a slice will need, setting its capacity from the start eliminates the repeated allocations and copies that would otherwise occur as the slice grows element by element. The make function allows you to separate length and capacity:
// Bad: multiple allocations
func convertBad(numbers []int) []string {
var strs []string
for _, n := range numbers {
strs = append(strs, strconv.Itoa(n))
}
return strs
}
// Better: one allocation with pre-sized capacity
func convertBetter(numbers []int) []string {
strs := make([]string, 0, len(numbers))
for _, n := range numbers {
strs = append(strs, strconv.Itoa(n))
}
return strs
}
In the first version, append will need to grow the backing array several times, copying the string slice each time. The second version allocates exactly the needed space once. In benchmarks, this simple change cuts allocations from many down to one and noticeably reduces execution time.
Pre-sizing pays off in hot loops:
If you see append inside a loop that processes known-size input, reach for make with capacity. It is a zero-cost habit that avoids silent performance degradation.
You can also set the length directly when you plan to assign by index:
strs := make([]string, len(numbers))
for i, n := range numbers {
strs[i] = strconv.Itoa(n)
}
This avoids the per‑element bounds checks that append performs and yields a small additional speedup, though the readability trade‑off depends on context.
Common Mistakes
1. Confusing length and capacity
New Gophers often write make([]int, cap) when they actually wanted to specify the capacity, because make([]T, n) sets both length and capacity to n. The result is a slice of n zero-valued elements, not an empty slice with room for n elements. To get an empty slice with reserved capacity, write make([]T, 0, cap).
2. Indexing beyond the length but within the capacity
A slice with length 3 and capacity 10 can access elements at indices 0, 1, and 2. Accessing index 3 panics, even though the underlying array has room for it. You must re-slice (s = s[:4]) to extend the visible window before indexing.
3. Assuming append modifies the slice in place
append returns a new slice header. If the capacity was sufficient, the returned header points to the same underlying array but with an increased length. If not, it points to a new array. Neglecting to capture the return value leaves your original slice unchanged and often causes data loss or stale views.
4. Holding large capacities after re-slicing a small piece
When you take a small sub-slice from a huge slice, the capacity of the sub-slice still spans to the end of the original array. The entire large array remains in memory because the sub-slice’s header references it, even if you only need a few elements. To release the memory, copy the needed elements into a new slice with no leftover capacity:
big := make([]byte, 1<<30) // 1 GiB
small := big[:4] // len 4, cap still 1 GiB
// Keep the large array alive unnecessarily
fixed := make([]byte, 4)
copy(fixed, small) // fixed has cap 4, big can be collected
5. Using nil slices without understanding len and cap
A nil slice (var s []int) has length 0 and capacity 0. It behaves like an empty slice when ranging, appending, or taking its length. You can safely append to a nil slice; Go will allocate a new backing array. Writing len(s) or cap(s) on a nil slice is valid and returns 0 — no panic occurs.
Summary
The interplay between length and capacity is what makes slices both flexible and efficient. Capacity is the ceiling on growth without reallocation; length is how many elements you are actively using. By slicing, you adjust the view; by appending, you either consume spare capacity or trigger a new allocation. Pre‑allocating capacity with make when the final size is known is one of the most reliable optimisations in everyday Go. The most dangerous edge cases — panics from over‑slicing, stale slice variables after append, and unintended retention of large arrays — all trace back to misreading or ignoring capacity.