Slice Expressions
Use simple and full slice expressions to create new slices from arrays slices or pointers to arrays in Go
A slice expression looks like a[low:high] or a[low:high:max]. It produces a new slice that refers to the same underlying array as the original, with a different start, length, and (optionally) capacity. No data is copied — the new slice is just a thin descriptor over the same memory.
Go offers two forms: simple ([low:high]) for everyday sub‑slicing, and full ([low:high:max]) when you also need to cap how far the slice can grow.
Simple Slice Expressions
A simple slice expression a[low:high] creates a slice whose elements are a[low] through a[high-1]. The result has length high - low and capacity cap(a) - low (for slices) or len(a) - low (for arrays).
// Slicing an array
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4]
// s is []int{20, 30, 40}
// len(s) == 3, cap(s) == 4 (len(arr) - 1)
When you slice a slice, the upper bound high can reach all the way to the capacity of the operand, not just its length. That means you can “re‑extend” a slice that was previously shortened.
parent := make([]int, 3, 10) // len 3, cap 10
child := parent[:2] // len 2, cap 10
grandchild := child[1:5] // len 4, cap 9 — valid because 5 ≤ cap(child)
Index bounds for slices vs. arrays:
For an array, high must stay ≤ len(a). For a slice, high can go up to cap(a). A common mistake is to assume the array rule applies everywhere and then be surprised that slicing a slice past its length works.
Omitting low defaults it to 0. Omitting high defaults it to the length of the operand (for arrays or strings) or to the length of the slice.
s := []int{1, 2, 3, 4, 5}
a := s[:3] // same as s[0:3]
b := s[2:] // same as s[2:len(s)]
c := s[:] // same as s[0:len(s)]
If low or high are out of range at run time, the program panics. Constant indices that are out of range cause a compile‑time error.
s := make([]int, 3)
// s[2:5] would panic: 5 > cap(s)
// s[-1:2] compilation error: index must be non‑negative
Runtime panic on out‑of‑bounds slicing:
Even if a slice has large capacity, a slice expression that references an index beyond cap(a) will panic. Always check your bounds when the high index is dynamic.
Full Slice Expressions
The full form a[low : high : max] adds a third index that sets the capacity of the resulting slice to max - low. It works only with arrays, pointers to arrays, and slices — strings are excluded.
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// Simple slice: capacity is len(arr) - low = 10 - 1 = 9
simple := arr[1:4] // [1 2 3], cap 9
// Full slice: capacity becomes max - low = 5 - 1 = 4
full := arr[1:4:5] // [1 2 3], cap 4
The bounds must satisfy 0 <= low <= high <= max <= cap(a). If any index is out of range, you get a compile‑time error or a runtime panic.
Capacity control is the whole point:
If you hand a sub‑slice to a caller and want to guarantee they cannot append beyond a certain point, use a full slice expression. It locks the capacity at a chosen limit, protecting data past that boundary.
Only low may be omitted in a full slice expression; it defaults to 0. Omitting high or max is not allowed.
s := []int{10, 20, 30, 40, 50}
t := s[:3:3] // len 3, cap 3
// t := s[:3:] would be a compile error
When you later slice the result again, the maximum capacity is still clamped by the original max. Even if the underlying array has room, the slice header’s capacity won’t expand past the bound you set.
base := make([]int, 3, 100)
small := base[:2:5] // cap = 5
// small = small[:cap(small)] yields len 5, cap 5
// cannot go to capacity 100 because the header cap was locked
The Underlying Array Is Shared
Every slice expression creates a view, not a copy. Changing an element through one slice changes it for every other slice that shares the same underlying array.
original := []int{10, 20, 30, 40}
view := original[1:3] // [20, 30]
view[0] = 99
// original is now [10, 99, 30, 40]
Accidental mutation is easy:
When you pass a slice to a function or store a sub‑slice, remember that modifications ripple back. If you need isolation, copy the data into a new slice with make + copy or append to a nil slice.
This sharing also affects memory. A small sub‑slice can keep an entire large array alive because the garbage collector sees the array as still reachable through the sub‑slice’s pointer.
func loadHugeData() []byte {
huge := make([]byte, 1<<30) // 1 GB
return huge[:1] // returns a 1-byte slice, but huge array stays
}
Small slice, large memory retention:
If you extract a tiny slice from a massive array and return it, the whole array remains in memory. To release the unused part, use a full slice expression to cap the capacity, or copy the needed elements to a fresh slice.
To fix the above:
func loadHugeDataSafe() []byte {
huge := make([]byte, 1<<30)
small := make([]byte, 1)
copy(small, huge[:1])
return small // only 1 byte of live memory
}
Or, if you must return a view of the same array but want to allow garbage collection of the rest, you cannot currently do that with pure slice expressions because a slice always points to the start of its underlying array. The common workaround is to copy the needed portion into a new slice.
How Beginners Should Think About Slice Expressions
Think of an array as a row of lockers, each holding a value. A slice is a sticky note that says “lockers 2 through 5” plus “I can see up to locker 10.” A simple slice expression writes a new sticky note with a different start and end, but the maximum visible lockers stays the same. A full slice expression also moves the end of the visible range inward, restricting how far the note can stretch.
This mental model helps you remember:
- The lockers themselves are shared.
- The sticky note doesn’t own the lockers.
- Slicing is cheap, but ownership never transfers.
When you append to a slice and it grows beyond its capacity, Go silently allocates a new, larger row of lockers, copies the old values over, and updates the sticky note. That’s why the capacity limit set by a full slice expression matters — it forces that re‑allocation sooner, which can protect other data or free memory.
Common Pitfalls and How to Avoid Them
- Confusing length and capacity after slicing. A slice may have a tiny length but still see many elements beyond it. Use
len()andcap()explicitly in tests to verify your assumptions. - Assuming slicing copies data. Every slice expression (simple or full) is a view. If you need a copy, make one explicitly.
- Over‑slicing and panics. When
highormaxcomes from a calculation, guard it. Especially remember that for slices,highcannot exceedcap(a), not justlen(a). - Memory retention from large backing arrays. This is especially dangerous in long‑lived caches or when returning small results from large scans. Full slice expressions can cap capacity, but if you still hold a pointer to the start of the array, the whole array stays. Copying is the surest fix.
When Full Slice Expressions Shine
The full form isn’t needed in most day‑to‑day code, but it is critical in two scenarios:
- Custom allocators or buffer managers. When you hand out sub‑slices of a larger pool, you want to prevent one tenant from accidentally writing past its allotted segment.
- Security‑sensitive code. Preventing
appendfrom overwriting adjacent data can stop subtle corruption bugs.
Here is a tiny buffer pool that uses the third index to enforce boundaries:
pool := make([]byte, 0, 1024)
// Allocate 64 bytes from pool
chunk := pool[:64:64] // len 64, cap 64 — cannot grow past 64
pool = pool[64:]
Because chunk has capacity locked at 64, any append that would exceed 64 bytes must allocate fresh memory, keeping the pool’s remaining space untouched.
Summary
Slice expressions are the mechanism that turns arrays and slices into flexible windows. Simple expressions ([low:high]) adjust the visible range; full expressions ([low:high:max]) also clamp how far that window can expand. Both are zero‑copy, which is powerful but requires vigilance: mutations are visible to all viewers, and small windows can pin large backing arrays in memory.
The single most important habit: always ask yourself what you want the capacity of the new slice to be. If the answer is “only the visible elements,” reach for the full slice expression. If the answer is “all the way to the end of the underlying array,” the simple form is enough — but know what that implies for memory.