Search and navigate

Pages

Start HereRoadmapTutorialsCheatsheetInterview QuestionsResources

Published tutorials

Building a REST API with Go: The Complete TutorialError Handling in Go: Wrapping, Classification, and APIsFan-Out Fan-In in Go: Parallel Work with ChannelsFuzz Testing in Go: Finding Bugs AutomaticallyGeneric Data Structures in Go: Stack, Queue and SetGeneric Functions in Go: Writing Reusable CodeGin in Go: Build and Test a Small JSON APIGo Benchmarking: Measuring Performance with testing.BGo Channel Patterns: Advanced Recipes That WorkGo Channels: Communication Between GoroutinesGo Concurrency Patterns: A Practical CatalogueGo Constraints: comparable, Ordered and Custom SetsGo Context: Cancellation, Deadlines, and Request ValuesGo defer: Cleanup, Ordering, and Common TrapsGo HTTP Client: Timeouts, JSON, and Reliable RequestsGo HTTP Middleware: Build a Production ChainGo Interview Questions: 25 Answers That Show UnderstandingGo Mutex: Protecting Shared State from Race ConditionsGo net/http: Build an HTTP Server from ScratchGo Reflection: When and How to Use reflectGo select Statement: Multiplexing Channels ExplainedGo Slice Internals: How Slices Really WorkGo Tools You Need for a Reliable Development LoopGo Tutorial: From First Program to Backend FoundationsGo Type Parameters: Syntax and Constraints ExplainedGo Worker Pool Pattern: Bounded Concurrency at ScaleGo's Garbage Collector Explained: How Memory Is ManagedGo's Type System Deep Dive: Static Typing Done RightGolang Compilation and Execution ExplainedGolang Developer Salary 2026: US, UK and RemoteGolang File Structure for Modules and ApplicationsGolang Hello World and Your First Go ProgramGolang IDEs: Choose an Editor for Your WorkflowGolang Interfaces: What They Are and How to Use ThemGoroutine Leaks in Go: How to Detect and Prevent ThemGoroutines in Go: Concurrency Without LeaksHow to Download and Install Golang SafelyHow to Learn Golang with a Focused Study PlanIntegration Testing in Go: Testing Real BoundariesJSON in Go: Encoding, Decoding, and ValidationLearn Golang with a Practical Beginner CourseMocking in Go: Test Doubles Without Magicsync.WaitGroup in Go: Coordinating Multiple GoroutinesTable-Driven Tests in Go: The Idiomatic WayTesting in Go: Table Tests, HTTP, Fakes, and CoverageWhat Is Go and How Does a Go Program Work?Why Learn Golang and What Is Go Used For?Writing Unit Tests in Go: A Practical Guide
Open full search and filters →

Go Slice Internals: How Slices Really Work

Go slice internals explained and proven: the three-word header, backing arrays, append reallocation, growth, and aliasing bugs. Tested on Go 1.24.7.

Standard-library lessonGo requirement: Go 1.21+How tutorials are checked
Generics · Lesson 8Saved in this browser. No account required.
A slice header containing pointer, length, and capacity connected to a shared backing array
A slice is a small descriptor that points to a segment of a backing array. Image: Golang Tutorial

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.