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 0 if omitted.
  • high — the index one past the last element included. Defaults to len(slice) if omitted. Must satisfy low <= high <= cap(slice).
  • max — only present in the three-index form; sets the result’s capacity to max - low. Must satisfy high <= 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:

  1. 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 for b.
  2. Go computes len(b) = 4 - 2 = 2 and cap(b) = cap(a) - 2.
  3. These three values (pointer, length, capacity) are stored in the new header b — no elements are copied anywhere.
  4. When append(b, x) runs, Go checks whether len(b) + 1 still fits inside cap(b).
  5. If it fits, Go writes x directly into the next free slot of the shared underlying array and returns a slice with length len(b) + 1, still pointing at the same array. This is exactly the case that can silently mutate data other slices are viewing.
  6. If it does not fit, Go allocates a new, larger array, copies every element of b into it, writes x at the end, and returns a slice pointing at the new array. From that point on, b and a are 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 make plus copy whenever 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 append on it, use the three-index form s[low:high:high] to remove spare capacity and force a fresh allocation instead of risking corruption of your original array.
  • Remember that high in s[low:high] can legally go up to cap(s), not just len(s) — be explicit about which one you actually mean.
  • Always reassign the result of append back 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 copy and append functions over hand-written index loops — they are well-tested and correctly handle overlapping ranges.

Practice Exercises

  1. Given letters := []byte("abcdefgh"), produce a sub-slice containing just cde using slice syntax, then print it as a string with string(...).
  2. Create a slice a with make([]int, 3, 6), filled with the values 1, 2, 3. Take b := a[:2], then append two elements to b. Predict, then verify by printing a and b, whether any elements of a at index 2 or beyond change.
  3. Write a function firstThree(s []int) []int that returns a fully independent copy of the first three elements of s (using make and copy), 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.
  • high can range up to cap(s), not just len(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, forcing append to allocate separately instead of aliasing the original array.
  • Use make plus copy whenever you need a truly independent slice, since slicing alone never duplicates data.