Slices Explained
A slice is the tool you reach for almost every time you need a list-like collection in Go, and it’s used far more often than the fixed-size array it’s built on top of. Under the hood a slice is a small struct with three fields — a pointer, a length, and a capacity — that together describe a window into an underlying array. That simple design is what makes slices cheap to pass around and resize, and it’s also the source of a few classic bugs when two slices end up quietly sharing the same memory. This lesson works through how slices are built, how append grows them, how to avoid the aliasing and comparison traps, and how experienced Go programmers use slices in real code.
Overview: How Slices Work Under the Hood
Go has two related sequence types. An array, written [5]int, has a size baked into its type — [5]int and [10]int are different types, arrays are copied by value on assignment, and their size can never change. A slice, written []int, has no size in its type at all. That’s because a slice isn’t the data itself — it’s a small header, roughly:
type sliceHeader struct {
array unsafe.Pointer // pointer to the first element the slice can see
len int // number of elements currently in the slice
cap int // number of elements available before reallocation
}
(That’s a simplified sketch of what the runtime actually stores, not a type you write yourself.) Every slice value you create — from a literal, from make, or by slicing an array or another slice — is one of these three-field headers pointing at some underlying array. Slicing an existing slice or array, as in sub := s[2:5], does not copy any elements. It creates a new header whose pointer starts at index 2 of the same backing array, whose length is 5 - 2 = 3, and whose capacity extends from index 2 to the end of the original backing array. Two slices produced this way share memory: writing to an element through one is visible through the other, because there is only ever one underlying array until something forces a copy.
That “something” is append. When you append to a slice whose length is still below its capacity, Go writes the new element directly into the existing backing array and returns a header with len incremented — no allocation happens, and any other slice sharing that array will see the write. But when length equals capacity, there’s no room left, so append allocates a brand-new, larger array, copies every existing element into it, writes the new element, and returns a header pointing at the new array. The old array is left untouched. This is precisely why you must always reassign the result of append back to a variable (s = append(s, x)): the returned header might describe a completely different array than the one s pointed at before the call, and if you discard the result, your original variable never finds out.
The zero value of a slice is nil — a header with a nil pointer and length and capacity both zero. Unlike a nil map, a nil slice is completely safe to use: len(s) is 0, ranging over it does nothing, and append(s, x) works fine and allocates a fresh array on the first call. You do not need to initialize a slice with []T{} just to avoid nil; the two are interchangeable for almost every purpose (they only differ if code explicitly tests for nilness, such as encoding/json, which encodes a nil slice as null but an empty non-nil slice as []).
Syntax
There are several ways to produce a slice, and a slice expression has up to three parts:
var s []T // nil slice, ready to use
s := []T{v1, v2, v3} // slice literal with initial elements
s := make([]T, length) // zero-valued slice of the given length
s := make([]T, length, capacity) // length elements, room for more before reallocating
sub := s[low:high] // elements s[low] through s[high-1]
sub := s[low:high:max] // same, but caps capacity at max-low
| Part | Meaning |
|---|---|
low |
index of the first element included (default 0 if omitted) |
high |
index one past the last element included (default len(s) if omitted) |
max |
optional third index; sets the resulting capacity to max - low, limiting how far append can grow in place before it must reallocate |
Examples
Example 1: Creating a slice and inspecting length and capacity
package main
import "fmt"
func main() {
numbers := []int{10, 20, 30, 40, 50}
fmt.Println("slice:", numbers)
fmt.Println("length:", len(numbers))
fmt.Println("capacity:", cap(numbers))
sub := numbers[1:4]
fmt.Println("sub:", sub)
numbers = append(numbers, 60)
fmt.Println("after append:", numbers)
}
Output:
slice: [10 20 30 40 50]
length: 5
capacity: 5
sub: [20 30 40]
after append: [10 20 30 40 50 60]
A slice literal allocates a backing array of exactly the right size, so len and cap start out equal. numbers[1:4] produces a view of three elements without copying anything. Appending one more element pushes past the original capacity of 5, so Go silently allocates a new backing array behind the scenes for numbers.
Example 2: Two slices sharing one backing array
package main
import "fmt"
func main() {
original := []int{1, 2, 3, 4, 5}
view := original[1:3]
fmt.Println("view before:", view)
view[0] = 99
fmt.Println("view after:", view)
fmt.Println("original after:", original)
view = append(view, 100)
fmt.Println("view after append:", view)
fmt.Println("original after append:", original)
}
Output:
view before: [2 3]
view after: [99 3]
original after: [1 99 3 4 5]
view after append: [99 3 100]
original after append: [1 99 3 100 5]
This is the core behavior to internalize. view and original point at the same array, so writing view[0] = 99 changes original[1] too. view has a capacity of 4 (from index 1 to the end of the 5-element array), so appending one element still fits without reallocating — it writes into original[3], overwriting the 4 that used to be there. Nothing here is a bug in Go; it’s exactly what slicing promises. The bug only appears when a programmer doesn’t expect the sharing.
Example 3: Filtering into a new slice and using copy for independence
package main
import "fmt"
func longNames(names []string, minLen int) []string {
var result []string
for _, name := range names {
if len(name) >= minLen {
result = append(result, name)
}
}
return result
}
func main() {
names := []string{"Al", "Grace", "Bo", "Alexandria", "Sam"}
long := longNames(names, 4)
fmt.Println("long names:", long)
backup := make([]string, len(names))
copied := copy(backup, names)
fmt.Println("copied elements:", copied)
fmt.Println("backup:", backup)
backup[0] = "Alan"
fmt.Println("backup after edit:", backup)
fmt.Println("names unaffected:", names)
}
Output:
long names: [Grace Alexandria]
copied elements: 5
backup: [Al Grace Bo Alexandria Sam]
backup after edit: [Alan Grace Bo Alexandria Sam]
names unaffected: [Al Grace Bo Alexandria Sam]
longNames starts from a nil slice (var result []string) and grows it with append — a completely normal, idiomatic pattern. To get a fully independent copy of names, we make a destination slice of the right length and use the built-in copy, which returns the number of elements actually copied. Because backup has its own backing array, mutating it never touches names.
How It Works Step by Step: Capacity Growth
When append needs more room than the current capacity provides, the runtime picks a new capacity, allocates a new array of that size, copies every old element across, and appends the new one. For small slices (roughly under a few hundred elements) the current Go runtime simply doubles the capacity each time it must grow, which you can observe directly:
package main
import "fmt"
func main() {
s := make([]int, 0)
for i := 0; i < 6; i++ {
s = append(s, i)
fmt.Println("len:", len(s), "cap:", 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
Step by step: s starts with length and capacity both 0. The first append has nowhere to grow into, so capacity jumps to 1. The second append is full again (len 1 == cap 1), so capacity doubles to 2. The third append doubles capacity again to 4, and the fourth append fits inside that capacity for free. The exact growth factor is an implementation detail the language spec doesn't guarantee — only that capacity never shrinks and appending is amortized O(1) — but doubling for small slices is what the current toolchain does, and it's why appending in a loop is efficient even though it occasionally reallocates.
Common Mistakes
Mistake 1: Discarding the result of append
append returns a new header; it does not modify the variable you passed in. Go's compiler actually refuses to compile a bare append(s, 4) statement whose result is thrown away entirely — but it happily lets you capture the result into the wrong variable, which is the version of this mistake that actually ships:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
extended := append(s, 4)
fmt.Println("s:", s)
fmt.Println("extended:", extended)
}
Output:
s: [1 2 3]
extended: [1 2 3 4]
s is unchanged because append returned a new slice header, and that new header was assigned to extended, not back into s. The fix is to reassign into the same variable you're extending:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println("s:", s)
}
Output:
s: [1 2 3 4]
Mistake 2: Unexpected aliasing when append still fits in the old array
package main
import "fmt"
func main() {
original := []int{1, 2, 3, 4, 5}
firstTwo := original[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("original:", original)
}
Output:
firstTwo: [1 2 99]
original: [1 2 99 4 5]
firstTwo has length 2 but capacity 5 (it can see all the way to the end of original's array), so appending one element fits without reallocating — and silently overwrites original[2]. If the intent was for firstTwo to be independent, use the three-index full slice expression to cap its capacity at its length, which forces append to allocate a fresh array immediately:
package main
import "fmt"
func main() {
original := []int{1, 2, 3, 4, 5}
firstTwo := original[:2:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("original:", original)
}
Output:
firstTwo: [1 2 99]
original: [1 2 3 4 5]
Mistake 3: Comparing slices with ==
Slices are not comparable with == except against nil; the compiler rejects it outright.
a := []int{1, 2, 3}
b := []int{1, 2, 3}
if a == b {
fmt.Println("equal")
}
// compile error: invalid operation: a == b (slice can only be compared to nil)
Use the standard library instead. Since Go 1.21, the slices package provides Equal:
package main
import (
"fmt"
"slices"
)
func main() {
a := []int{1, 2, 3}
b := []int{1, 2, 3}
fmt.Println("equal:", slices.Equal(a, b))
}
Output:
equal: true
Best Practices
- Always reassign the result of
appendto a variable — the returned header may point at a different backing array than the one you started with. - When you know roughly how many elements you'll end up with, preallocate with
make([]T, 0, n)to avoid repeated reallocation as the slice grows. - When a function must not let the caller's data be mutated or grown into, use
copyinto a fresh slice, or slice the input with a full slice expression (s[low:high:max]) soappendis forced to allocate. - Never compare slices with
==; useslices.Equal(Go 1.21+) or write an explicit element-by-element comparison. - Don't bother initializing with
[]T{}just to avoidnil— a nil slice is safe to range over and append to. Only reach for an explicit empty literal when code downstream (like JSON encoding) distinguishes nil from empty. - Remember that passing a slice to a function passes a copy of the header, not the data — the callee can mutate existing elements through it, but appending inside the callee won't be visible to the caller unless the new slice is returned.
Practice Exercises
- Write a function
evens(nums []int) []intthat returns a new slice containing only the even numbers fromnums, without modifying the input slice. Test it on[]int{1, 2, 3, 4, 5, 6}and confirm the output is[2 4 6]. - Given
s := make([]int, 3, 5)followed by threeappendcalls that add one element each, predict thelenandcapprinted after each call before you run it, then check your reasoning against the doubling behavior described above. - Write two functions, one that takes a slice and mutates its first element directly (no full slice expression), and one that uses
copyto work on an independent snapshot. Call both frommainon the same source slice and print the source afterward to see the difference.
Summary
- A slice is a small header — pointer, length, and capacity — describing a window into an underlying array; it is not the data itself.
- Slicing a slice or array never copies elements; the result shares the same backing array until something forces a copy.
appendwrites in place when there's spare capacity (visible to other slices sharing that array), or allocates a new, larger array and copies everything over when there isn't — always capture its return value.- A nil slice is safe to use directly with
len,range, andappend; you don't need an empty literal just to avoid nil. - Use
copyor a full slice expression (s[low:high:max]) when you need to guarantee independence from a shared backing array. - Slices cannot be compared with
==; useslices.Equalor a manual comparison instead.
