Arrays and Slices Under the Hood in Go
Explore Go arrays and slices through runnable examples of backing arrays, length, capacity, append, copying, and the mutations that shared storage makes possible.
You take the first two elements of a slice and append a third. Another part of the program suddenly sees one of its elements replaced. Add more elements to the append, and the unexpected mutation disappears.
The difference is capacity. A slice describes a view of a backing array. Appending may reuse that array or create a new one. Understanding which case applies is more useful than treating a slice as a growable array with private storage.
This is Part 3 of Go Under the Hood. Part 2 explained why copied values can still share data. Here we apply that model to arrays, slicing, and append.
You should recognise variables, functions, and basic slice syntax. The examples target Go 1.27 and were verified with Go 1.27.1 on macOS ARM64. They use only the standard library and make no performance claims.
An array contains elements; a slice describes a view
An array’s length belongs to its type: [3]int and [4]int are different types. Assigning an array copies its elements. A slice has type []T and describes an array segment using a reference, length, and capacity. Copying that description leaves the elements shared. Go blog: slice usage and internals
| Operation | Useful question |
|---|---|
b := a where a is an array | Which element values were copied? |
t := s where s is a slice | Which backing array do both views reach? |
s[i] | Is i within the current length? |
s[:n] | Is n within the available capacity? |
append(s, x) | Does the additional element fit? |
Length controls accessible elements; capacity controls how far the slice can extend from its starting position. A shorter view does not automatically protect the remaining elements of the array.
Experiment 1: trace the view into the array
Using a Go 1.27 toolchain, create a module:
go version
mkdir slice-demo
cd slice-demo
go mod init example.com/slice-demo
go mod edit -go=1.27.0
Save this as main.go:
package main
import "fmt"
func main() {
a := [5]int{10, 20, 30, 40, 50}
b := a
b[0] = 99
fmt.Println("arrays:", a, b)
s := a[1:3]
fmt.Println("view:", s, len(s), cap(s))
s[0] = 200
t := s[:cap(s)]
fmt.Println("extended:", t)
fmt.Println("array:", a)
}
Run each complete program in this article with:
go run .
go vet ./...
Expected output:
arrays: [10 20 30 40 50] [99 20 30 40 50]
view: [20 30] 2 4
extended: [200 30 40 50]
array: [10 200 30 40 50]
The copy b owns independent integer elements. The slice s reaches into a, starting at index 1. Its length is two, but four elements remain from that starting position.
array index: 0 1 2 3 4
array a: [10] [200] [30] [40] [50]
s, len=2: └────────┘
t, len=4: └────────────────────┘
Reslicing constructs t; it does not change s’s length. The write through s[0] is visible in a and t because all three paths reach the same element.
For a deliberate failure, add fmt.Println(s[2]) after the existing prints. It panics: s still has length two, even though it has spare capacity. Restore the program afterwards. Use t[2] to access that position through the extended view.
Experiment 2: append can overwrite a sibling view
Replace main.go with:
package main
import "fmt"
func main() {
base := []int{10, 20, 30, 40}
left := base[:2]
right := base[2:]
grown := append(left, 99)
fmt.Println("base:", base)
fmt.Println("left:", left, len(left), cap(left))
fmt.Println("right:", right)
fmt.Println("grown:", grown)
limited := base[:2:2]
detached := append(limited, 77)
detached[0] = 500
fmt.Println("after detached append:", base, detached)
}
Expected output:
base: [10 20 99 40]
left: [10 20] 2 4
right: [99 40]
grown: [10 20 99]
after detached append: [10 20 99 40] [500 20 77]
The first append has room in the original array. It writes 99 at index 2, which is also right[0]. Nothing is wrong with the append; the mistaken assumption was that left owned separate storage.
Notice that left still has length two. append returns the resulting slice, so retain that result when you need the new length or backing array. If the required length exceeds capacity, it creates a sufficiently large new array; otherwise it reuses the existing one. Go specification: append
The full slice expression base[:2:2] creates a view with length and capacity both two. More generally, s[low:high:max] sets length to high-low and capacity to max-low. Go specification: full slice expressions
Appending one element to limited cannot fit. The returned detached uses new storage, so its later integer-element mutation leaves base unchanged.
Limit capacity when extending must not touch neighbours
A capacity limit is useful when handing a view to code that may append. It is not an immutable view: before the append, limited[0] = 500 would still change base[0].
slices.Clip(s) returns s[:len(s):len(s)]. It limits capacity without cloning the elements. slices.Clone(s) makes a shallow element copy into a clone; reference-containing elements can still share their targets. slices.Clip, slices.Clone
Choose the operation around the contract:
| Requirement | Appropriate starting point |
|---|---|
| Share existing elements, prevent append from overwriting the following region | Full slice expression or slices.Clip |
| Independently mutate the integers in a slice | slices.Clone or make plus copy |
| Independently mutate objects reached through pointer elements | Copy those objects according to an explicit ownership policy |
A small subslice can also keep a much larger backing array reachable. Clipping capacity does not detach it. Copy the needed elements when retaining that shared array is undesirable. Go blog: a possible memory-retention issue
Experiment 3: copy uses destination length
Replace main.go again:
package main
import "fmt"
func main() {
src := []int{3, 6, 9}
dst := make([]int, 0, len(src))
n := copy(dst, src)
fmt.Println("empty destination:", n, dst)
dst = dst[:len(src)]
n = copy(dst, src)
dst[0] = 30
fmt.Println("sized destination:", n, src, dst)
overlap := []int{1, 2, 3, 4}
n = copy(overlap[1:], overlap[:3])
fmt.Println("overlap:", n, overlap)
}
Expected output:
empty destination: 0 []
sized destination: 3 [3 6 9] [30 6 9]
overlap: 3 [1 1 2 3]
The first copy writes nothing. Reserving capacity did not create any visible destination elements. Reslicing makes those slots available; the next copy fills them. A simpler initial destination would be make([]int, len(src)).
copy returns the smaller of the source and destination lengths and supports overlapping regions. It neither grows the destination nor changes its length. Go specification: copy
The overlapping example shifts three values right. Compare the output with what a naive left-to-right assignment loop would do: overwriting the first source element too early would propagate the wrong value. The built-in operation handles that overlap.
Choose length and capacity for different jobs
For an append-based builder, make([]int, 0, n) starts empty with room for n elements. For indexed writes into n existing positions, use make([]int, n). Mixing those intentions often introduces leading zero values or an out-of-range panic.
Try this separate program:
package main
import "fmt"
func main() {
var zero []int
empty := make([]int, 0)
fmt.Println("nil:", zero == nil, len(zero), cap(zero))
fmt.Println("empty:", empty == nil, len(empty), cap(empty))
builder := make([]int, 0, 3)
builder = append(builder, 7)
filled := make([]int, 3)
filled = append(filled, 7)
fmt.Println("builder:", builder)
fmt.Println("filled:", filled)
}
It prints:
nil: true 0 0
empty: false 0 0
builder: [7]
filled: [0 0 0 7]
Both empty forms have zero length, but only zero is nil. Neither can be indexed at position zero before gaining an element. The filled slice already has three integer elements when the append begins.
Capacity growth is an implementation detail
Do not write correctness checks that expect capacity to double after every append. The language promises sufficient space when growth is necessary, not a fixed growth ratio.
For runtime investigation, Go 1.27.1’s growslice and nextslicecap are useful starting points. Capacity selection and allocation rounding belong to this implementation; they are separate from the observable sharing rules demonstrated above. Go 1.27.1 runtime slice source
The examples deliberately avoid asserting capacity after growth. They assert values and sharing instead. Preallocate when a useful size estimate exists, but measure before claiming an allocation or speed improvement. A Measurement-First Performance Investigation provides the broader workflow.
Exercise: predict both append paths
Return to Experiment 2 and change left := base[:2] to left := base[:2:2]. Predict the output before running it. base should remain [10 20 30 40], and right should remain [30 40]; grown still becomes [10 20 99].
Restore the original two-index slice, then change the first append to append(left, 99, 88, 77). Five elements cannot fit in its capacity of four, so base and right again remain unchanged. This time grown becomes [10 20 99 88 77].
Finally, change the destination in Experiment 3 to make([]int, len(src)). The first copy now returns three and prints [3 6 9].
When reviewing slice code, trace the starting position, length, capacity, and other views of the same array. Those four facts explain the surprising mutations in these experiments. The next planned part, Strings, Bytes, and Runes, examines text representation and how byte slices differ from immutable strings.