Skip to content
gohacks.dev
Basics

Slices in Go From the Slice Header to the Backing Array

How a Go slice is built from a pointer, a length and a capacity, why append sometimes changes the caller's data, and the slices package helpers worth knowing.

14 min read
Summarize in AI
Flat illustration of an editor window full of code.

The slice is the data structure you will use more than any other in Go, and the one whose behavior most often contradicts what a newcomer expects. A function that appends to its argument does not change the caller's length. Two variables that look like separate lists change each other's contents. A slice that was "emptied" still pins a gigabyte of memory. All three come from one fact: a slice is not the data, it is a small description of a window onto data that lives somewhere else. This guide builds that model from the header up, runs each claim, and finishes with the operations you will do every day. It is one of the central articles in the Go basics path, it follows arrays, and the append trap shows the most expensive consequence in detail.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

What a slice is made of

A slice value has three fields: a pointer to the first element it can see, a length (how many elements it exposes) and a capacity (how many elements exist from the pointer to the end of the underlying array). The type is written []T, and a slice of a different element type is a different type. The header is the same size whatever the element type or length.

main.goGo
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var nilSlice []int
	empty := []int{}
	made := make([]int, 3, 10)

	fmt.Println(unsafe.Sizeof(nilSlice), unsafe.Sizeof(made))
	fmt.Println(len(nilSlice), cap(nilSlice), nilSlice == nil)
	fmt.Println(len(empty), cap(empty), empty == nil)
	fmt.Println(len(made), cap(made), made)
}
Text
24 24
0 0 true
0 0 false
3 10 [0 0 0]

The header is 24 bytes, three 8-byte words, for every slice. The nil slice has a zero pointer, length 0 and capacity 0. The empty slice has length 0 as well, but it points at a real (zero-size) location, so empty == nil is false. make([]int, 3, 10) allocates room for ten integers, exposes three of them as zero values and keeps seven in reserve, which is what cap reports. The rule that follows: a slice is only nil if it has no array at all, and most code should not care.

len(s) and cap(s) are cheap, since they read the header. And a nil slice is fully usable: len, range and append all work. That is the Go convention that the zero value should be useful, and it is why code almost never initializes a slice before filling it.

Nil and empty are the same except to a JSON encoder

The one place the difference shows is serialization. encoding/json writes a nil slice as null and an empty one as []:

main.goGo
package main

import (
	"encoding/json"
	"fmt"
)

type Response struct {
	Items    []string `json:"items"`
	Selected []string `json:"selected"`
}

func main() {
	out, _ := json.Marshal(Response{Items: nil, Selected: []string{}})
	fmt.Println(string(out))
}
Text
{"items":null,"selected":[]}

A JavaScript client that does response.items.length will throw on the null and be fine on the []. If you write APIs, decide which you promise and make the handler consistent, usually by initializing the response field to an empty slice. A test for that is cheap and has caught real bugs. Mind the comparison helper in that test: reflect.DeepEqual treats a nil slice and an empty one as different, while slices.Equal treats them as equal, so pick the one that matches the promise you are making.

Slicing makes a new header on the same array

The expression s[i:j] produces a new slice whose pointer is advanced by i elements, whose length is j - i, and whose capacity is the original capacity minus i. Nothing is copied. That is why slicing is fast, and why it has the consequences below.

main.goGo
package main

import "fmt"

func main() {
	a := []int{0, 1, 2, 3, 4, 5, 6, 7}
	b := a[2:5]
	fmt.Println(b, len(b), cap(b))

	b[0] = 99
	fmt.Println("after write:", a)

	b = append(b, 100)
	fmt.Println("after append:", a, b)

	c := a[2:5:5]
	c = append(c, 200)
	fmt.Println("after capped append:", a, c)
}
Text
[2 3 4] 3 6
after write: [0 1 99 3 4 5 6 7]
after append: [0 1 99 3 4 100 6 7] [99 3 4 100]
after capped append: [0 1 99 3 4 100 6 7] [99 3 4 200]

b sees elements 2, 3 and 4 and has room to go to the end of a, so its capacity is 6. Writing b[0] changed a[2], because they are the same memory. The append is the surprise: b had length 3 and capacity 6, so the new element was written into the next slot of the same array, which is a[5], and a changed without anyone touching it. The last case uses the three-index form a[2:5:5] to cap the capacity at 3, so the append has no room, allocates a fresh array, and a is untouched. The full slice expression is covered in full slice expressions and capacity control.

The practical rule is the one the Go team states in its own article on slices: when you hand a slice to someone else, assume they can see and change every element up to its capacity, not just its length, unless you cap it.

How append grows a slice

append has two paths. If len < cap, it writes the new element into the existing array and returns a header with a longer length. Otherwise it allocates a bigger array, copies the old elements over, adds the new one and returns a header pointing at the new array. The old array is garbage if nothing else references it. This is why the result must be assigned, and why append(s, x) on its own line, with the result discarded, is a compile error.

Growth is not one element at a time. The runtime picks a new capacity so that a long run of appends does not copy the whole slice on each step. Here is what it does for []int, in two functions that differ only in whether the slice outlives the call:

main.goGo
package main

import "fmt"

var sink []int

func caps(s []int, n int) ([]int, []int) {
	var seen []int
	last := -1
	for i := range n {
		s = append(s, i)
		if cap(s) != last {
			seen = append(seen, cap(s))
			last = cap(s)
		}
	}
	return s, seen
}

func main() {
	local, seenLocal := caps(nil, 2000)
	_ = local
	fmt.Println(seenLocal)

	var s []int
	last := -1
	for i := range 2000 {
		s = append(s, i)
		if cap(s) != last {
			fmt.Print(cap(s), " ")
			last = cap(s)
		}
	}
	sink = s
	fmt.Println()
}
Text
[4 8 16 32 64 128 256 512 848 1280 1792 2560]
1 2 4 8 16 32 64 128 256 512 848 1280 1792 2560

Both lines show the same shape, and the beginnings differ. In the second loop the slice is stored in a package variable, so it lives on the heap and the capacities go 1 2 4 8 from the first append. In the first function the compiler can prove the slice does not escape, and Go 1.25 is allowed to give such a slice a small buffer on the stack, so the first append already has room for four integers. The behavior is invisible except through cap, and it is a good example of why you should not hard-code the sequence. After that start, the capacity doubles while the slice is small, then the factor falls toward 1.25. Since Go 1.18 the change of regime begins at 256 elements and is gradual, and every new capacity is rounded up to a memory size class, which is why the large values are not neat multiples of anything. The growth rule is documented only as amortized constant time, and it has changed between releases. What you can rely on is that cap(s) is at least len(s) after an append.

Preallocate when you know the size

If you know how many elements are coming, or can estimate, tell make. The allocations are the cost you can measure:

main.goGo
package main

import (
	"fmt"
	"testing"
)

var sink []int

func main() {
	grow := testing.AllocsPerRun(20, func() {
		var s []int
		for i := range 1000 {
			s = append(s, i)
		}
		sink = s
	})

	prealloc := testing.AllocsPerRun(20, func() {
		s := make([]int, 0, 1000)
		for i := range 1000 {
			s = append(s, i)
		}
		sink = s
	})

	fmt.Println("append from nil:", grow)
	fmt.Println("make with capacity:", prealloc)
}
Text
append from nil: 12
make with capacity: 1

Appending a thousand integers to a nil slice took twelve allocations, one per growth step from capacity 1 up to 1,280, and every one copied the data so far. The preallocated version took one. For a thousand elements on a laptop the saved time is microseconds, and you will not notice it. It becomes a real cost in a hot path, like a request handler that builds a result list on every call, or in a loop over a million rows. I preallocate whenever the size is known or cheaply bounded, such as make([]Result, 0, len(inputs)), and I do not bother when it is a guess.

What passing a slice to a function really does

Go passes everything by value, and a slice is a value: the 24-byte header. The callee gets its own copy of pointer, length and capacity. So element writes are visible to the caller, because the pointer is shared, and header changes are not:

main.goGo
package main

import "fmt"

func modify(s []int) {
	s[0] = 100
	s = append(s, 4)
	s[1] = 200
}

func main() {
	tight := []int{1, 2, 3}
	modify(tight)
	fmt.Println("full:", tight, len(tight), cap(tight))

	roomy := make([]int, 3, 10)
	copy(roomy, []int{1, 2, 3})
	modify(roomy)
	fmt.Println("roomy:", roomy, len(roomy), cap(roomy))
	fmt.Println("roomy reslice:", roomy[:4])
}
Text
full: [100 2 3] 3 3
roomy: [100 200 3] 3 10
roomy reslice: [100 200 3 4]

Both callers see s[0] = 100. The difference comes at the append. For tight, capacity equals length, so append allocates a new array, and the later s[1] = 200 writes into that new array, which the caller never sees. For roomy, the append wrote into spare capacity of the shared array, so s[1] = 200 did change the caller's data, and the caller's own length is still 3. The extra element 4 is hidden, but it is there if you reslice: roomy[:4] shows it.

So a function that changes the length of a slice must return the new slice, and the caller must reassign it: items = removeBad(items). A function that only edits elements can take the slice and return nothing. Both are normal Go. The sloppy case is a function that does both and only sometimes works, depending on capacity. If you read code that does s = append(s, ...) on a parameter and never returns it, that is the bug.

The everyday operations, with the slices package

Go 1.21 added the slices package to the standard library, and it replaced most of the helper loops that every project used to write. The ones I use constantly:

main.goGo
package main

import (
	"fmt"
	"slices"
)

func main() {
	s := []int{5, 2, 8, 2, 9}

	fmt.Println(slices.Contains(s, 8), slices.Index(s, 2), slices.Max(s))

	t := slices.Clone(s)
	slices.Sort(t)
	fmt.Println(s, t)
	fmt.Println(slices.Compact(slices.Clone(t)))

	t = slices.Insert(t, 1, 100, 101)
	fmt.Println(t)

	t = slices.Delete(t, 1, 3)
	fmt.Println(t)

	idx, found := slices.BinarySearch(t, 8)
	fmt.Println(idx, found)

	slices.Reverse(t)
	fmt.Println(t, slices.Equal(s, t))

	evens := slices.DeleteFunc(slices.Clone(s), func(n int) bool { return n%2 != 0 })
	fmt.Println(evens)
}
Text
true 1 9
[5 2 8 2 9] [2 2 5 8 9]
[2 5 8 9]
[2 100 101 2 5 8 9]
[2 2 5 8 9]
3 true
[9 8 5 2 2] false
[2 8 2]

Two details matter. slices.Sort sorts in place and returns nothing, so clone first when you need the original, as I did. And Insert, Delete and Compact return the new slice and must be assigned back. For sorting by something other than the natural order, slices.SortFunc takes a comparison function, and sorting anything with sort and slices goes through the cases.

Deleting, filtering and the memory you forgot

Removing an element is append(s[:i], s[i+1:]...), or slices.Delete(s, i, i+1). Filtering in place reuses the array: loop over the input, append the keepers to s[:0]. Both leave the old tail elements in the backing array, and if those elements are pointers, the garbage collector still sees them. A slice of pointers to large objects that has been "shortened" keeps every dropped object alive until the array is freed or overwritten. Since Go 1.22, slices.Delete zeroes the elements it vacates, so the leak is gone for that function. If you write the loop by hand, zero the tail yourself with clear(s[len(s):cap(s)]) or assign nil to the dropped slots. Deleting an element without leaking it shows the exact steps.

The same mechanism causes the other classic leak: slicing a small piece out of a big slice. small := big[:10] keeps the whole of big reachable. If big is a 100 MB read buffer and small lives in a cache, you hold 100 MB. Copy what you keep, with slices.Clone(big[:10]), and let the buffer go. Copying is also how you break aliasing deliberately, and copying a slice the safe way lists the options.

Make with a length when you meant a capacity

One mistake shows up in nearly every Go code review for a newcomer. make([]int, n) creates a slice with n elements already in it, all zero. Appending to it adds after those zeros:

main.goGo
package main

import "fmt"

func main() {
	wrong := make([]int, 3)
	for i := range 3 {
		wrong = append(wrong, i)
	}
	fmt.Println(wrong, len(wrong))

	right := make([]int, 0, 3)
	for i := range 3 {
		right = append(right, i)
	}
	fmt.Println(right, len(right))

	indexed := make([]int, 3)
	for i := range indexed {
		indexed[i] = i * 10
	}
	fmt.Println(indexed)
}
Text
[0 0 0 0 1 2] 6
[0 1 2] 3
[0 10 20]

The first slice has six elements, three zeros followed by the values. The fix depends on how you fill it. If you are going to append, ask for length zero and a capacity. If you are going to assign by index, ask for the length and do not append. Pick one style per slice and the bug cannot occur. The compiler will not help, since both forms are legal, and the symptom is a result that begins with a run of zeros.

Checking whether two slices share memory

When you suspect aliasing, ask the program. Two slices alias if their backing arrays overlap, and comparing the address of an element is a quick, honest test:

main.goGo
package main

import (
	"fmt"
	"unsafe"
)

func sharesStart(a, b []int) bool {
	if cap(a) == 0 || cap(b) == 0 {
		return false
	}
	return unsafe.SliceData(a) == unsafe.SliceData(b)
}

func main() {
	a := make([]int, 4, 8)
	b := a[:2]
	c := append([]int(nil), a...)
	d := a[2:]

	fmt.Println(sharesStart(a, b), sharesStart(a, c), sharesStart(a, d))
	fmt.Println(&a[2] == &d[0], &a[2] == &c[2])
}
Text
true false false
true false

unsafe.SliceData returns a pointer to the first element, so the same pointer means the same array and the same starting position. d starts two elements later, so its first element is not the same address as a's first, but &a[2] == &d[0] is true, which is the reliable test for "these overlap at this index". The package name unsafe is a warning for production code, and for debugging and tests the check is fine. I keep a helper like this in the test file when a function promises to return a copy, and a test that asserts the result does not alias the input is cheap insurance.

Filtering in place without allocating

A pattern worth knowing by heart is filtering by reusing the input's array. Start with an empty slice that shares the same storage, append the elements to keep, and the result is built in place:

main.goGo
package main

import "fmt"

type Job struct {
	ID   int
	Done bool
}

func pending(jobs []Job) []Job {
	out := jobs[:0]
	for _, j := range jobs {
		if !j.Done {
			out = append(out, j)
		}
	}
	clear(jobs[len(out):])
	return out
}

func main() {
	jobs := []Job{{1, true}, {2, false}, {3, false}, {4, true}, {5, false}}
	left := pending(jobs)
	fmt.Println(left)
	fmt.Println(jobs)
}
Text
[{2 false} {3 false} {5 false}]
[{2 false} {3 false} {5 false} {0 false} {0 false}]

The result is [{2 false} {3 false} {5 false}] and no allocation happened. The input slice has been changed underneath: the original jobs now begins with the kept elements and ends with zeroed ones, which the last line prints. That is the cost of the trick. Use it when the caller gives up the input, as in a pipeline stage. If the caller still needs the original, allocate a new slice or use slices.DeleteFunc on a clone. The clear call zeroes the tail, so any pointers in the vacated elements stop keeping their targets alive.

Slices of slices

A [][]int is a slice whose elements are slices, each with its own header and its own array. The rows can have different lengths, and each row is a separate allocation. That is flexible for ragged data and costly for a dense grid. For a matrix of known width, one flat []float64 of length rows*cols and an index r*cols + c is a single allocation, has far better cache behavior and is what numeric code usually does. Allocating a [][]int of 1,000 by 1,000 means a thousand and one allocations, plus a header per row.

Looping over a slice

for i, v := range s evaluates s once at the start, so appending to s inside the loop does not extend the iteration, and v is a copy of each element, so modifying v does not change the slice. To modify in place, assign through the index: s[i] = .... When the element is a large struct, ranging by value copies it on every step, and indexing is cheaper. Range over slices, maps, strings and channels covers the full rules, including the change that gave each iteration its own variable in Go 1.22.

When a slice is the wrong container

A slice is the right default, and it has limits. Frequent inserts or deletes in the middle of a large sequence cost a copy of everything after them, and if that is your workload, measure before reaching for container/list, which has poor locality and an allocation per element. For membership tests on a large set, a map[T]struct{} beats scanning with slices.Contains. A fixed-size, comparable value that should be a map key wants an array, not a slice. And if multiple goroutines write the same slice, no amount of header understanding saves you. Goroutines writing to different elements of a slice that already has its final length are safe, because each element is a separate memory location. Goroutines calling append on the same slice variable are a data race, since each one reads and writes the shared header, and the failure is lost elements or a corrupted length that appears only under load. Give each goroutine its own index range, collect results over a channel, or guard the slice with a mutex, and run the tests with go test -race to prove it. The Go blog's introduction to slices is still the clearest official explanation, and the slices package documentation lists every helper used above. Maps are the other everyday container, and maps in Go without surprises is the sensible next read.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.