Slicing a Slice
In Go, a slice is a lightweight view into an underlying array, and you can take a “slice of a slice” using the same [low:high] syntax you’d use on an array. Slicing a slice never copies any data — it produces a brand-new slice header that points into the exact same backing array as the original. Understanding this single fact explains some of Go’s most surprising behavior: two apparently independent slices that mysteriously affect each other, and append calls that silently overwrite data you never touched.
Overview: How Slicing a Slice Works
Every slice value in Go is a small struct with three fields: a pointer to the first element it can see, a length (len), and a capacity (cap). None of these fields contain a copy of the data itself — the actual elements live in a separate underlying array, and the slice is only a “window” onto some contiguous run of that array.
When you write sub := original[low:high], Go does not allocate a new array. It computes a new pointer (the original’s pointer advanced by low elements), a new length (high - low), and a new capacity (cap(original) - low), and packages those three values into a new slice header. The new slice sub and the original slice original now both point at the same underlying array. Any element the two ranges overlap on is shared: writing through one is visible through the other.
This has a subtle but important consequence for which indices are legal. The upper bound high in original[low:high] is not limited by len(original) — it is limited by cap(original). That means you can slice “past the end” of a slice’s visible length and still land inside the same backing array, exposing elements that already exist there (often, but not always, zero values left over from allocation). This is exactly what lets append grow a slice cheaply, in place, before it is finally forced to reallocate.
Go also supports a three-index (“full”) slice expression: slice[low:high:max]. The third index caps the resulting slice’s capacity at max - low, even if the underlying array has more room than that. This is the standard tool for deliberately limiting how far a sub-slice can grow via append before Go is forced to allocate a fresh array — which, as the examples below show, is often exactly what you want when handing a sub-slice to other code.
Syntax
slice[low:high]
slice[low:high:max]
- slice — any existing slice (or array, or pointer to an array) you want a window into.
- low — the index of the first element included in the result. Defaults to
0if omitted. - high — the index one past the last element included. Defaults to
len(slice)if omitted. Must satisfylow <= high <= cap(slice). - max — only present in the three-index form; sets the result’s capacity to
max - low. Must satisfyhigh <= max <= cap(slice).
Examples
Example 1: Basic Slicing With len and cap
package main
import "fmt"
func main() {
numbers := []int{10, 20, 30, 40, 50, 60}
middle := numbers[1:4]
fmt.Println(middle)
fmt.Println(len(middle), cap(middle))
}
Output:
[20 30 40]
3 5
Slicing numbers[1:4] selects indices 1, 2, and 3 (index 4 is excluded), giving [20 30 40] with length 3. Its capacity, however, is 5, not 3 — capacity always counts from low to the end of the original’s backing array (cap(numbers) - low = 6 - 1), regardless of how many elements the new slice’s length exposes.
Example 2: Sub-Slices Share the Same Backing Array
package main
import "fmt"
func main() {
original := []int{1, 2, 3, 4, 5}
sub := original[1:3]
sub[0] = 99
fmt.Println("original:", original)
fmt.Println("sub:", sub)
}
Output:
original: [1 99 3 4 5]
sub: [99 3]
Writing to sub[0] actually writes to original[1], because they are two different views of the very same array element. This is the core mental model to keep: slicing a slice never clones data, so mutations through any alias are visible through every other alias that covers the same index.
Example 3: Limiting Capacity With the Three-Index Slice
package main
import "fmt"
func main() {
source := []int{1, 2, 3, 4, 5}
limited := source[1:3:4]
fmt.Println("limited:", limited)
fmt.Println("len:", len(limited), "cap:", cap(limited))
}
Output:
limited: [2 3]
len: 2 cap: 3
The three-index form source[1:3:4] still selects the same elements as source[1:3] (length 2), but the third index, 4, fixes the capacity to 4 - 1 = 3 instead of the default cap(source) - 1 = 4. That one extra unit of capacity headroom (versus zero) is intentional here purely to show how max controls capacity independently of length — in practice you’ll often set max equal to high to remove all spare capacity, as the next section demonstrates.
How It Works Step by Step
Consider b := a[2:4] followed later by b = append(b, x). Here is what actually happens:
- Go reads
a‘s slice header to find the underlying array’s pointer, and advances it by 2 element-widths to get the starting address forb. - Go computes
len(b) = 4 - 2 = 2andcap(b) = cap(a) - 2. - These three values (pointer, length, capacity) are stored in the new header
b— no elements are copied anywhere. - When
append(b, x)runs, Go checks whetherlen(b) + 1still fits insidecap(b). - If it fits, Go writes
xdirectly into the next free slot of the shared underlying array and returns a slice with lengthlen(b) + 1, still pointing at the same array. This is exactly the case that can silently mutate data other slices are viewing. - If it does not fit, Go allocates a new, larger array, copies every element of
binto it, writesxat the end, and returns a slice pointing at the new array. From that point on,bandaare completely independent — further changes to one never affect the other.
Common Mistakes
Mistake 1: Assuming a Slice Expression Copies the Data
New Go programmers often reach for s[:] or a plain re-slice expecting an independent copy, the way slicing works in some other languages. It does not — you get another view of the same array.
package main
import "fmt"
func main() {
source := []int{1, 2, 3}
copySlice := source[:]
copySlice[0] = 999
fmt.Println("source:", source)
fmt.Println("copySlice:", copySlice)
}
Output:
source: [999 2 3]
copySlice: [999 2 3]
Both variables changed because source[:] is still the same underlying array. To get a real, independent copy, allocate a new backing array with make and use the built-in copy function:
package main
import "fmt"
func main() {
source := []int{1, 2, 3}
copySlice := make([]int, len(source))
copy(copySlice, source)
copySlice[0] = 999
fmt.Println("source:", source)
fmt.Println("copySlice:", copySlice)
}
Output:
source: [1 2 3]
copySlice: [999 2 3]
Mistake 2: append Silently Corrupting a Sibling Slice
When two slices are carved from the same array and one still has spare capacity, appending to it can overwrite elements the other slice is looking at — with no error, warning, or panic.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4, 5}
a := base[0:2]
b := base[2:4]
a = append(a, 100)
fmt.Println("base:", base)
fmt.Println("a:", a)
fmt.Println("b:", b)
}
Output:
base: [1 2 100 4 5]
a: [1 2 100]
b: [100 4]
a has length 2 but capacity 5, so append had room to grow in place: it wrote 100 into base[2], the very slot b starts at. b was never touched directly, yet its first element changed. The fix is to cap a‘s capacity with the three-index form so append is forced to allocate a separate array instead of reaching into shared memory:
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4, 5}
a := base[0:2:2]
b := base[2:4]
a = append(a, 100)
fmt.Println("base:", base)
fmt.Println("a:", a)
fmt.Println("b:", b)
}
Output:
base: [1 2 3 4 5]
a: [1 2 100]
b: [3 4]
Now a‘s capacity equals its length, so append has to allocate a new array immediately, leaving base and b untouched.
Mistake 3: Slicing Past Capacity Panics at Runtime
Because high in s[low:high] is checked against cap(s), not len(s), it’s easy to assume any index up to some remembered “size” is safe when it is actually beyond the array’s real capacity:
s := make([]int, 3, 5)
bad := s[:10] // panic: runtime error: slice bounds out of range [:10] with capacity 5
The fix is to bound the expression by the slice’s actual capacity, using cap(s) directly instead of a guessed constant:
package main
import "fmt"
func main() {
s := make([]int, 3, 5)
safe := s[:cap(s)]
fmt.Println("safe:", safe)
fmt.Println("len:", len(safe), "cap:", cap(safe))
}
Output:
safe: [0 0 0 0 0]
len: 5 cap: 5
s[:cap(s)] reveals the two extra elements Go had already reserved when make([]int, 3, 5) allocated the backing array; since Go zero-initializes new arrays, those extra slots show up as 0.
Best Practices
- Use
makepluscopywhenever you need an independent copy of a slice’s data — re-slicing alone never copies. - When handing a sub-slice to code that might call
appendon it, use the three-index forms[low:high:high]to remove spare capacity and force a fresh allocation instead of risking corruption of your original array. - Remember that
highins[low:high]can legally go up tocap(s), not justlen(s)— be explicit about which one you actually mean. - Always reassign the result of
appendback to a variable (s = append(s, x)); never assume the original variable is updated automatically. - When two slices might alias the same array and that matters, either document the aliasing clearly or eliminate the ambiguity entirely by copying.
- Prefer the built-in
copyandappendfunctions over hand-written index loops — they are well-tested and correctly handle overlapping ranges.
Practice Exercises
- Given
letters := []byte("abcdefgh"), produce a sub-slice containing justcdeusing slice syntax, then print it as a string withstring(...). - Create a slice
awithmake([]int, 3, 6), filled with the values1, 2, 3. Takeb := a[:2], then append two elements tob. Predict, then verify by printingaandb, whether any elements ofaat index 2 or beyond change. - Write a function
firstThree(s []int) []intthat returns a fully independent copy of the first three elements ofs(usingmakeandcopy), so that modifying the result never affects the caller’s original slice.
Summary
- Slicing a slice (
s[low:high]) creates a new slice header, not a new array — the original and the result share the same underlying data. highcan range up tocap(s), not justlen(s), so re-slicing can reveal elements outside the original’s visible length.- Appending to a sub-slice that still has spare capacity overwrites the shared array in place, which can silently corrupt data other slices are viewing.
- The three-index expression
s[low:high:max]caps a sub-slice’s capacity, forcingappendto allocate separately instead of aliasing the original array. - Use
makepluscopywhenever you need a truly independent slice, since slicing alone never duplicates data.
