Slices Explained: Length, Capacity, and Backing Arrays
Oct 2026 · 5 min read
A Go slice is a descriptor that holds a pointer to a backing array, a length, and a capacity. Slicing creates an alias to the same array, while append may reuse the old array or allocate a new one, so the result must be used again.
TL;DR: A Go slice is a descriptor that holds a pointer to a backing array, a length, and a capacity. Slicing creates an alias to the same array, while append may reuse the old array or allocate a new one, so the result must be used again. Full code: https://github.com/Sch39/go-series
Problem
A slice looks like an ordinary collection, but a slice value is only a descriptor to a backing array. Bugs appear when developers mix up the number of visible elements (len), the available room (cap), and the array shared between slices.
Append does not automatically update the caller:
appendreturns the resulting slice and that result must be captured.Aliasing is invisible: two slices can point to the same backing array, so mutating an element through one slice is visible from the other slice.
Outcome
Readers can:
Explain the difference between
lenandcapon a slice.Use the result of
appendand predict when the backing array changes.Distinguish backing array aliasing from an append isolated with a full slice expression.
Implementation
Folder Structure
go-series/G01-03-slices/
├── go.mod
└── slices/
├── slices.go
└── slices_test.goSetup
Initialize one module (run from the go-series/ folder):
mkdir -p G01-03-slices
cd G01-03-slices
go mod init github.com/sch39/go-series/g0103Code Blocks
Block 1: Slice descriptor — length and capacity
go-series/G01-03-slices/slices/slices.go:
package slices
func Describe(values []int) (int, int) {
return len(values), cap(values)
}Goal: Show that a slice carries len as the number of visible elements and cap as the room from the starting position to the end of the backing array.
Block 2: Append returns the resulting slice
go-series/G01-03-slices/slices/slices.go:
func AppendOne(values []int, value int) []int {
return append(values, value)
}Goal: Prove that the result of append must be used because the operation can change the length and can replace the backing array.
Block 3: Slicing creates a backing array alias
go-series/G01-03-slices/slices/slices.go:
func SetFirst(values []int, value int) {
values[0] = value
}Goal: Show that mutating an element through one slice can be visible in another slice that shares the backing array.
Block 4: Full slice expression limits capacity
go-series/G01-03-slices/slices/slices.go:
func IsolateAppend(values []int, value int) []int {
isolated := values[:len(values):len(values)]
return append(isolated, value)
}Goal: Show that a limited capacity forces append to allocate a new backing array.
Test Blocks
Test 1: Length, capacity, and append
go-series/G01-03-slices/slices/slices_test.go:
package slices_test
import (
"testing"
"github.com/sch39/go-series/g0103/slices"
)
func TestLengthCapacityAndAppend(t *testing.T) {
values := make([]int, 2, 4)
length, capacity := slices.Describe(values)
if length != 2 || capacity != 4 {
t.Fatalf("Describe() = len %d cap %d, want len 2 cap 4", length, capacity)
}
grown := slices.AppendOne(values, 7)
t.Logf("before len=%d cap=%d; after=%v len=%d cap=%d", length, capacity, grown, len(grown), cap(grown))
if len(grown) != 3 || grown[2] != 7 {
t.Errorf("AppendOne() = %v, want third element 7", grown)
}
}Goal: Prove the values of len and cap, and that append produces a slice with the added element.
Test 2: Aliasing and isolated append
go-series/G01-03-slices/slices/slices_test.go:
func TestAliasingAndIsolatedAppend(t *testing.T) {
values := make([]int, 2, 3)
values[0] = 10
alias := values[:1]
slices.SetFirst(alias, 99)
t.Logf("values after alias mutation=%v", values)
if values[0] != 99 {
t.Errorf("SetFirst() did not mutate shared backing array; got %v", values)
}
isolated := slices.IsolateAppend(values[:1], 42)
t.Logf("original=%v isolated=%v", values, isolated)
if len(isolated) != 2 || isolated[1] != 42 {
t.Errorf("IsolateAppend() = %v, want [99 42]", isolated)
}
if len(values) != 2 || values[1] != 0 {
t.Errorf("IsolateAppend() changed original length/data; got %v", values)
}
}Goal: Prove that an alias mutation is visible, but the full slice expression prevents append from writing to the old backing array.
Run Test
Run from the go-series/G01-03-slices/ folder:
go test -count=1 -v ./...Output:
=== RUN TestLengthCapacityAndAppend
slices_test.go:17: before len=2 cap=4; after=[0 0 7] len=3 cap=4
--- PASS: TestLengthCapacityAndAppend (0.00s)
=== RUN TestAliasingAndIsolatedAppend
slices_test.go:28: values after alias mutation=[99 0]
slices_test.go:34: original=[99 0] isolated=[99 42]
--- PASS: TestAliasingAndIsolatedAppend (0.00s)
PASS
ok github.com/sch39/go-series/g0103/slices 3.613sWhy It Works
Slice descriptor: A slice is a descriptor that points to a segment of an array and carries len and cap. len limits the elements accessible through the slice, while cap is the capacity from the position of the first element to the end of the backing array.
Backing array and aliasing: Assignment or slicing does not copy elements into a new array. Two slices can point to the same array, so a change to an element through one is visible through the other.
Append: append adds elements and returns the resulting slice. If the capacity is sufficient, the backing array can be reused; if not, the runtime can allocate a new array. The full slice expression s[:len(s):len(s)] limits the capacity so that the next append does not use the extra room in the old backing array.
Edge Cases
Append without assignment: Calling append(values, x) without using the return value does not update the caller's slice length, even though the old backing array may have been written to.
Sub-slice retains a large array: A small sub-slice still holds a reference to a large backing array. Copy the part you need if the sub-slice's lifetime is much longer than the original array's.
Nil and empty slice: A nil slice is safe for len, cap, and append; len(nil) and cap(nil) are zero. A nil slice differs from a non-nil empty slice when compared with nil or serialized.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
Reuse backing array | Minimal allocation and efficient | Aliasing can leak mutations |
Ordinary append | Simple and idiomatic API | Backing array can change, the result must be captured |
Full slice expression | Isolates append from the old capacity | Can trigger a new allocation sooner |
Best practice: Always use the return value of append. Be aware of aliasing when sharing sub-slices, and limit capacity with a full slice expression when append must not modify the shared backing array.
Production Checklist
After
append, store the return value into the slice you use.When modifying an element of a sub-slice, check whether the backing array is shared.
Use
lenfor the number of visible elements andcapfor the append room.Limit capacity with
s[:len(s):len(s)]if append must be isolated.Avoid keeping a small sub-slice around too long if the original backing array is very large.
References
Go Slices: usage and internals: https://go.dev/doc/articles/slices_usage_and_internals.html
Effective Go, Slices: https://go.dev/doc/effective_go.html#slices
Go 1.2 Release Notes, Three-index slices: https://go.dev/doc/go1.2#three_index