sch39
Part 3 of 4 — Go Fundamentals for Developers

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.

  1. Append does not automatically update the caller: append returns the resulting slice and that result must be captured.

  2. 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 len and cap on a slice.

  • Use the result of append and predict when the backing array changes.

  • Distinguish backing array aliasing from an append isolated with a full slice expression.

Implementation

Folder Structure

text
go-series/G01-03-slices/
├── go.mod
└── slices/
    ├── slices.go
    └── slices_test.go

Setup

Initialize one module (run from the go-series/ folder):

bash
mkdir -p G01-03-slices
cd G01-03-slices

go mod init github.com/sch39/go-series/g0103

Code Blocks

Block 1: Slice descriptor — length and capacity

go-series/G01-03-slices/slices/slices.go:

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:

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:

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:

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:

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:

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:

bash
go test -count=1 -v ./...

Output:

text
=== 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.613s

Why 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 len for the number of visible elements and cap for 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