Go Slice Internals: How Slices Really Work
This explains how Go slices work underneath: the three-word header, the backing array, and the rules append follows. Every behavior here is demonstrated with a program run on Go 1.24.7, including the pointer that changes when memory reallocates and the append that silently corrupts a caller’s data. The examples were reviewed against Go 1.26.5. After this, you can predict slice behavior instead of guessing.
Works with Go 1.21+ (the unsafe header trick and everything else runs unchanged on current Go).
This article goes deeper than day-to-day slice syntax to explain the machinery underneath. If you are new to Go, read the complete Go tutorial first. The type-system guide provides useful background on values and pointers, while the generics guide shows how slices interact with reusable algorithms.
A slice is a three-word header, not a container
A slice value is not the data. It is a small struct, 24 bytes on a 64-bit machine, with exactly three fields: a pointer to an element of a backing array, a length, and a capacity. That struct is what the runtime calls the slice header. The elements live somewhere else, in the backing array the pointer references.
You can read length and capacity with the built-ins, and confirm the header is 24 bytes:
package main
import (
"fmt"
"unsafe"
)
func main() {
requests := make([]int, 3, 8)
fmt.Println("len:", len(requests), "cap:", cap(requests))
fmt.Println("header size (bytes):", unsafe.Sizeof(requests))
}
len: 3 cap: 8
header size (bytes): 24
Length is how many elements the slice can index. Capacity is how many elements exist between the pointer and the end of the backing array. make([]int, 3, 8) allocated a backing array of 8 ints and handed back a header with length 3 and capacity 8.
The three fields are not a metaphor. You can read them directly by reinterpreting the header’s memory as three machine words:
package main
import (
"fmt"
"unsafe"
)
func main() {
nums := make([]int, 3, 8)
// A slice header is three words: data pointer, len, cap.
header := (*[3]uintptr)(unsafe.Pointer(&nums))
fmt.Printf("data=0x%x len=%d cap=%d\n", header[0], header[1], header[2])
fmt.Println("built-ins agree: len", len(nums), "cap", cap(nums))
}
data=0xc00007ce78 len=3 cap=8
built-ins agree: len 3 cap 8
That first word is the address of nums[0]. Never write code like this in production; it exists here to show the header is real. The Go team’s own Go Slices: usage and internals on go.dev draws the same three-field picture. Every behavior below is those three fields doing exactly what they say.
Slicing makes a new header over the same array
The expression s[low:high] copies no elements. It builds a new header pointing into the same backing array: the pointer moves to element low, length becomes high - low, and capacity runs from low to the end of the original array. Two slices carved from one array share memory, so a write through one is visible through the other.
package main
import "fmt"
func main() {
backing := []int{10, 20, 30, 40, 50}
left := backing[0:3]
right := backing[2:5]
fmt.Println("left: ", left, "cap:", cap(left))
fmt.Println("right:", right, "cap:", cap(right))
left[2] = 999 // index 2 in backing, seen by both slices
fmt.Println("after left[2] = 999")
fmt.Println("left: ", left)
fmt.Println("right:", right)
}
left: [10 20 30] cap: 5
right: [30 40 50] cap: 3
after left[2] = 999
left: [10 20 999]
right: [999 40 50]
left[2] and right[0] are the same memory cell: index 2 of backing. One assignment changed what both slices see. Notice the capacities: left starts at index 0 so it can see all 5 elements, while right starts at index 2 so only 3 remain. That shared-memory model is a feature for zero-copy sub-slicing and a trap the moment append enters.
Append and the reallocation boundary
append follows one rule. If the slice has spare capacity, it writes the new element into the existing backing array and returns a header with length raised by one, same pointer. If capacity is full, it allocates a larger backing array, copies every element across, and returns a header pointing at the new array. That is why you always write s = append(s, v): the returned header may point somewhere new, and the old one will not know.
You can watch the boundary by printing the address of the first element. It stays fixed while there is room and jumps when the array is reallocated:
package main
import "fmt"
func main() {
nums := make([]int, 3, 4) // len 3, cap 4
fmt.Printf("start: len=%d cap=%d addr=%p\n", len(nums), cap(nums), &nums[0])
nums = append(nums, 99) // fits in cap 4, no reallocation
fmt.Printf("append1: len=%d cap=%d addr=%p\n", len(nums), cap(nums), &nums[0])
nums = append(nums, 100) // exceeds cap 4, reallocates
fmt.Printf("append2: len=%d cap=%d addr=%p\n", len(nums), cap(nums), &nums[0])
}
start: len=3 cap=4 addr=0xc00009e000
append1: len=4 cap=4 addr=0xc00009e000
append2: len=5 cap=8 addr=0xc0000a2040
The first append kept the address 0xc00009e000: it wrote into the existing array. The second crossed the capacity boundary, so the address changed to 0xc0000a2040. That new address is the proof a copy happened. Everything the old slice pointed at is now stale.
The aliasing bug: append within spare capacity
Here is where sharing and append collide. Slice a longer array down to a shorter length and the result carries the leftover capacity with it. Appending to that short slice writes into the array the original still uses.
package main
import "fmt"
func main() {
buffer := []int{10, 20, 30, 40, 50}
head := buffer[:2] // len 2, cap 5
fmt.Printf("head len=%d cap=%d\n", len(head), cap(head))
head = append(head, 999) // spare capacity: writes into buffer[2]
fmt.Println("head: ", head)
fmt.Println("buffer:", buffer)
}
head len=2 cap=5
head: [10 20 999]
buffer: [10 20 999 40 50]
Nothing panicked. No vet warning. buffer[2] was silently overwritten because head had capacity 5 and room to grow in place. This exact shape ships in real services: a function slices a shared buffer, appends to its piece, and corrupts data another caller is still reading.
Full slice expressions control capacity
The three-index form s[low:high:max] sets capacity to max - low. Set capacity equal to length and any future append is forced to reallocate before it can reach shared memory:
package main
import "fmt"
func main() {
buffer := []int{10, 20, 30, 40, 50}
head := buffer[:2:2] // len 2, cap 2: no room to grow in place
fmt.Printf("head len=%d cap=%d\n", len(head), cap(head))
head = append(head, 999) // cap full: forces a new backing array
fmt.Println("head: ", head)
fmt.Println("buffer:", buffer)
}
head len=2 cap=2
head: [10 20 999]
buffer: [10 20 30 40 50]
buffer survived intact. This is the correct tool whenever you hand a sub-slice to code you do not control, especially code that might append. It costs nothing until an append actually happens, and then it does the safe thing automatically.
copy makes independent slices
copy(dst, src) moves min(len(dst), len(src)) elements into dst’s backing array and returns the count. Note the minimum: copy never grows dst, so a destination shorter than the source truncates silently.
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3) // shorter than src
n := copy(dst, src) // copies min(3, 5) = 3
fmt.Println("copied:", n, "dst:", dst)
dst[0] = -1
fmt.Println("src unchanged:", src)
}
copied: 3 dst: [1 2 3]
src unchanged: [1 2 3 4 5]
dst owns its own backing array, so writing dst[0] left src alone. Since Go 1.21, slices.Clone(s) does the allocate-then-copy in one call when you want a full independent duplicate.
nil and empty slices: both len 0, not the same
A nil slice has a nil data pointer, length 0, capacity 0. An empty slice has a non-nil pointer to a zero-length array, length 0, capacity 0. Both report len and cap of 0, and you can append and range over either. They differ only in the nil comparison, which matters for JSON encoding and for APIs that treat nil as absent.
package main
import "fmt"
func main() {
var nilSlice []int
emptySlice := []int{}
madeSlice := make([]int, 0)
fmt.Println("nil: len", len(nilSlice), "cap", cap(nilSlice), "isNil", nilSlice == nil)
fmt.Println("empty: len", len(emptySlice), "cap", cap(emptySlice), "isNil", emptySlice == nil)
fmt.Println("made: len", len(madeSlice), "cap", cap(madeSlice), "isNil", madeSlice == nil)
nilSlice = append(nilSlice, 1) // append to nil is fine
fmt.Println("appended to nil:", nilSlice)
}
nil: len 0 cap 0 isNil true
empty: len 0 cap 0 isNil false
made: len 0 cap 0 isNil false
appended to nil: [1]
Practical rule: prefer var s []T (nil) as the zero value and let append allocate. Never test emptiness with s == nil; use len(s) == 0, which is true for both cases.
The growth strategy, measured on Go 1.24
How much bigger is the reallocated array? The reliable answer is to measure it for the Go version you use. This program prints length and capacity every time append reallocates:
package main
import "fmt"
func main() {
var ids []int
prevCap := -1
for i := 0; i < 2000; i++ {
ids = append(ids, i)
if cap(ids) != prevCap {
fmt.Printf("len=%4d cap=%4d\n", len(ids), cap(ids))
prevCap = cap(ids)
}
}
}
len= 1 cap= 1
len= 2 cap= 2
len= 3 cap= 4
len= 5 cap= 8
len= 9 cap= 16
len= 17 cap= 32
len= 33 cap= 64
len= 65 cap= 128
len= 129 cap= 256
len= 257 cap= 512
len= 513 cap= 848
len= 849 cap=1280
len=1281 cap=1792
len=1793 cap=2560
That is the real output on Go 1.24.7. Capacity doubles up to 256 elements, then growth tapers toward roughly 25 percent per step. The odd numbers (848, 1280, 1792) come from the runtime rounding each allocation up to an internal size class, not from a clean formula. The important history: the growth threshold changed. Older material claims a flat 2x factor, but the transition to the gentler large-slice growth landed in Go 1.18 and the exact numbers are runtime internals that can shift again. The spec guarantees none of these values, so never depend on an exact capacity.
What you can depend on: appending n elements one at a time is O(n) amortized, but each reallocation copies the whole slice, and every reallocation is a fresh heap allocation the garbage collector must later reclaim. When you know the final size, say it: make([]User, 0, len(rows)) before a loop of appends does one allocation instead of a dozen and shows up directly in allocation profiles.
Passing a slice to a function copies the header, shares the array
Go passes everything by value, slices included. The callee gets its own copy of the 24-byte header, but that copy points at the same backing array. So writes to elements are visible to the caller, while changes to the header itself (a new length, or a reallocation from append) are not.
package main
import "fmt"
func doubleAll(values []int) {
for i := range values {
values[i] *= 2 // writes through the shared pointer
}
}
func appendTotal(values []int) {
values = append(values, 100) // grows the local header copy only
}
func main() {
prices := []int{5, 10, 15}
doubleAll(prices)
fmt.Println("after doubleAll: ", prices)
appendTotal(prices)
fmt.Println("after appendTotal:", prices)
}
after doubleAll: [10 20 30]
after appendTotal: [10 20 30]
doubleAll reached through the shared pointer and its writes stuck. appendTotal built a longer slice, assigned it to its local header copy, and threw that copy away on return, so the caller saw nothing. The idiomatic fix is to return the slice the way append itself does: prices = appendTotal(prices). These are the pass-by-value mechanics covered in the type-system guide: a slice header is a value that happens to contain a pointer.
Slices of slices share too
A [][]int is a slice whose elements are themselves slice headers. Copying the outer slice copies the inner headers, which still point at the same inner backing arrays. So grabbing a row and mutating it reaches back into the grid:
package main
import "fmt"
func main() {
grid := [][]int{
{1, 2, 3},
{4, 5, 6},
}
row := grid[0]
row[0] = 99 // row shares grid[0]'s backing array
fmt.Println("grid:", grid)
fmt.Println("len rows:", len(grid), "len row0:", len(grid[0]))
}
grid: [[99 2 3] [4 5 6]]
len rows: 2 len row0: 3
Each inner slice has its own independent backing array, so rows can have different lengths (a jagged slice). But row aliases grid[0], not a copy of it. To hold an independent row, slices.Clone(grid[0]).
The memory retention gotcha, proven with the heap
A sub-slice keeps its entire backing array alive. If you slice three bytes out of a 10 MB buffer and hold onto the result, the garbage collector cannot free any of the 10 MB, because the small slice’s pointer still references that array. This one leaks memory in production log parsers and network code all the time. Here it is, measured against the live heap:
package main
import (
"fmt"
"runtime"
)
// Returns the first 3 bytes but keeps the whole 10MB array alive.
func firstThreeLeaky(huge []byte) []byte {
return huge[:3]
}
// Copies the 3 bytes so the huge array can be collected.
func firstThreeSafe(huge []byte) []byte {
out := make([]byte, 3)
copy(out, huge[:3])
return out
}
func heapMB() float64 {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
return float64(m.HeapAlloc) / (1024 * 1024)
}
func main() {
fmt.Printf("baseline heap: %.1f MB\n", heapMB())
huge := make([]byte, 10*1024*1024)
leaky := firstThreeLeaky(huge)
huge = nil
fmt.Printf("leaky retained: %.1f MB (keeps %d bytes)\n", heapMB(), len(leaky))
runtime.KeepAlive(leaky)
huge2 := make([]byte, 10*1024*1024)
safe := firstThreeSafe(huge2)
huge2 = nil
fmt.Printf("safe retained: %.1f MB (keeps %d bytes)\n", heapMB(), len(safe))
runtime.KeepAlive(safe)
}
baseline heap: 0.1 MB
leaky retained: 10.1 MB (keeps 3 bytes)
safe retained: 0.1 MB (keeps 3 bytes)
The numbers are unambiguous. Both functions return three bytes, but the leaky version holds 10.1 MB after a forced GC while the safe version holds 0.1 MB. The fix is always the same: when you keep a small piece of a large buffer past the buffer’s natural lifetime, copy the piece. slices.Clone or an explicit make plus copy both break the reference to the big array so the garbage collector can reclaim it.
Layer 3: a subslice that corrupts caller data, diagnosed and fixed
Here is how the bug appears in application code. Imagine a network layer where a packet is [4-byte header][payload]. A parser returns the header as a sub-slice of the packet buffer, cheap and zero-copy. The caller keeps a slice of the payload. Later, a downstream stage appends a checksum byte to the header slice.
package main
import "fmt"
// packet = [4-byte header][payload...]
func parseHeaderBuggy(packet []byte) []byte {
return packet[:4] // len 4, cap == len(packet): shares the payload region
}
func main() {
packet := []byte{0xDE, 0xAD, 0xBE, 0xEF, 'h', 'i', '!'}
header := parseHeaderBuggy(packet)
payload := packet[4:]
fmt.Printf("header: % X\n", header)
fmt.Printf("payload: %q\n", payload)
// A later stage appends a checksum byte to the header slice.
// header has spare capacity, so append writes into the payload region.
header = append(header, 0x01)
fmt.Printf("payload after append: %q <- corrupted\n", payload)
}
header: DE AD BE EF
payload: "hi!"
payload after append: "\x01i! <- corrupted"
The diagnosis is now mechanical. parseHeaderBuggy returned a header with length 4 but capacity 7, the full packet. The append had spare capacity, so it wrote 0x01 into packet[4], which is the first byte of the payload the caller still held. The h became \x01. No panic, no error return, just silently wrong bytes flowing downstream.
The fix is the three-index slice. Cap the capacity to the header length so any append is forced to allocate its own array:
package main
import "fmt"
func parseHeaderSafe(packet []byte) []byte {
return packet[:4:4] // three-index: cap capped to 4, append must reallocate
}
func main() {
packet := []byte{0xDE, 0xAD, 0xBE, 0xEF, 'h', 'i', '!'}
header := parseHeaderSafe(packet)
payload := packet[4:]
header = append(header, 0x01) // reallocates, leaves packet untouched
fmt.Printf("header: % X\n", header)
fmt.Printf("payload after append: %q <- intact\n", payload)
}
header: DE AD BE EF 01
payload after append: "hi!" <- intact
One character, :4:4 instead of :4, moved the append off the shared array. When a function returns a sub-slice that outlives its caller’s assumptions, either cap it with a three-index slice or copy it. Those are the only two safe options.
Common mistakes
Assuming append always copies
It does not. Append copies only when capacity is exhausted. Within spare capacity it mutates the shared array in place, which is the entire source of the aliasing bugs above. Before you append to a slice you did not allocate yourself, ask what its capacity is.
Fanning out multiple appends from one base
Appending different values to the same base slice, each with spare capacity, makes them fight over the same slot:
package main
import "fmt"
func main() {
base := make([]string, 0, 4)
base = append(base, "go")
withSlices := append(base, "slices")
withMaps := append(base, "maps") // reuses the same spare slot
fmt.Println("withSlices:", withSlices)
fmt.Println("withMaps: ", withMaps)
}
withSlices: [go maps]
withMaps: [go maps]
The second append overwrote what the first wrote, because both targeted base’s index 1. Never branch multiple appends off one base slice. Sever the sharing first with slices.Clone or a three-index slice.
Leaking large arrays through small slices
Covered above with heap numbers: a sub-slice pins its whole backing array. Copy the piece you keep when the source is large and long-lived.
Confusing nil and empty
s == nil is false for []int{} even though its length is 0. Test emptiness with len(s) == 0, and pick nil as your zero value unless an API specifically needs a non-nil empty slice (some JSON contracts distinguish null from []).
Depending on exact capacity after append
The growth numbers are runtime internals that already changed once and carry no spec guarantee. Depend on amortized O(n) behavior and pre-size with make when you know the count; never hard-code an expected capacity.
What next
You now have the model that makes every slice behavior predictable: a three-word header, one backing array, and append’s copy-only-when-full rule. From here:
- The generic data structures guide applies slices to reusable stacks and queues.
- The type-system guide explains the pass-by-value rules that slice headers follow, which is why passing a slice shares its array but not its length.
- The Go garbage collector explains why a pinned backing array is not reclaimed and how the collector decides what is still reachable.
- The complete Go tutorial ties these pieces into the rest of the language if you want the full path.
Worth bookmarking: the Go team’s Go Slices: usage and internals and the spec section on appending and copying.