Go's Slices Are Pointers With a Trick: Capacity, Aliasing and the Growth Algorithm

A Go slice is a three-word header over a shared backing array, and the rules that follow from that - capacity, aliasing, the 2x-then-1.25x growth curve and the memmove on every reallocation - explain most of the bugs people hit with slices.

TL;DR: A slice is three words — a pointer to the backing array, a length, and a capacity — and the pointer is shared. append allocates a new backing array when it runs out of room and memmoves the old contents across; below 256 elements it doubles, above that it grows by about 1.25x. Every aliasing bug you have ever had with a slice comes from that shared pointer.

Slices are Go’s most-used data structure and, at the same time, the one with the most surprising rules. The reason is simple: a slice is a view onto memory that somebody else owns. Once you pass it around, two slices can point at the same array, and writing through one is visible through the other.

This is a walk through the machinery, based on reading the Go 1.28 development tree.

The header

Everything is in src/runtime/slice.go, and the header is the whole concept:

go
// src/runtime/slice.go:16
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

Three words. The unsafe.Pointer is the start of the backing array, len is how many elements you can see, cap is how many elements the backing array actually holds.

len and cap are not the same thing, and the gap between them is the whole reason append sometimes allocates and sometimes does not. The language spec calls that gap the “capacity” available for growth.

Note what is not in the header: nothing about ownership, nothing about refcounting, no pointer to the end of the array. If you have a slice, you have a pointer to a start and a number, and that is that.

Making a slice

make([]T, len, cap) goes through makeslice:

go
// src/runtime/slice.go:102
func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		// NOTE: Produce a 'len out of range' error instead of a
		// 'cap out of range' error when someone does make([]T, bignumber).
		// 'cap out of range' is true too, but since the cap is only being
		// supplied implicitly, saying len is clearer.
		// See golang.org/issue/4085.
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

The allocation size is cap * sizeof(T), not len * sizeof(T). Ask for a slice with a big capacity and a small length and you get a big allocation.

The overflow check is the interesting part, because it is the thing that turns a silly program into a crash instead of a wrap-around:

go
// src/runtime/slice.go:29
func panicmakeslicelen() {
	panic(errorString("makeslice: len out of range"))
}

func panicmakeslicecap() {
	panic(errorString("makeslice: cap out of range"))
}

math.MulUintptr returns an overflow flag, so make([]byte, 1<<40) on a 32-bit machine panics rather than allocating four gigabytes of nothing.

There is a separate entry point for the make([]T, n) form, where the capacity is implied:

go
// src/runtime/slice.go:39
func makeslicecopy(et *_type, tolen int, fromlen int, from unsafe.Pointer) unsafe.Pointer {
	var tomem, copymem uintptr
	if uintptr(tolen) > uintptr(fromlen) {
		var overflow bool
		tomem, overflow = math.MulUintptr(et.Size_, uintptr(tolen))
		if overflow || tomem > maxAlloc || tolen < 0 {
			panicmakeslicelen()
		}
		copymem = et.Size_ * uintptr(fromlen)
	} else {
		// fromlen is a known good length providing and equal or greater than tolen,
		// thereby making tolen a good slice length too as from and to slices have the
		// same element width.
		tomem = et.Size_ * uintptr(tolen)
		copymem = tomem
	}

	var to unsafe.Pointer
	if !et.Pointers() {
		to = mallocgc(tomem, nil, false)
		if copymem < tomem {
			memclrNoHeapPointers(add(to, copymem), tomem-copymem)
		}
	} else {
		// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		to = mallocgc(tomem, et, true)
		if copymem > 0 && writeBarrier.enabled {

That is append(s, other...) and the copy-style constructor. Two things worth noting: pointer-free element types skip zeroing (the allocator hands back dirty memory and growslice clears only the tail), and pointer- containing types must be zeroed because the garbage collector will scan them.

What append actually does

The compiler rewrites append(s, v...) into a call to growslice when the slice is full. growslice is the interesting function, and its contract is spelled out in its own comment:

go
// src/runtime/slice.go:134
// growslice allocates new backing store for a slice.
//
// arguments:
//
//	oldPtr = pointer to the slice's backing array
//	newLen = new length (= oldLen + num)
//	oldCap = original slice's capacity.
//	   num = number of elements being added
//	    et = element type
//
// return values:
//
//	newPtr = pointer to the new backing store
//	newLen = same value as the argument
//	newCap = capacity of the new backing store
//
// Requires that uint(newLen) > uint(oldCap).
// Assumes the original slice length is newLen - num
//
// A new backing store is allocated with space for at least newLen elements.
// Existing entries [0, oldLen) are copied over to the new backing store.
// Added entries [oldLen, newLen) are not initialized by growslice
// (although for pointer-containing element types, they are zeroed). They
// must be initialized by the caller.
// Trailing entries [newLen, newCap) are zeroed.
//
// growslice's odd calling convention makes the generated code that calls
// this function simpler. In particular, it accepts and returns the
// new length so that the old length is not live (does not need to be
// spilled/restored) and the new length is returned (also does not need
// to be spilled/restored).

The compiler-facing contract is a little odd on purpose: the new length is passed in and returned so the register allocator does not have to keep the old length alive across the call.

The size arithmetic is specialised per element size, because the division and multiplication would otherwise show up in the profile of every append in the program:

go
// src/runtime/slice.go:195
	if et.Size_ == 0 {
		// append should not create a slice with nil pointer but non-zero len.
		// We assume that append doesn't need to preserve oldPtr in this case.
		return slice{unsafe.Pointer(&zerobase), newLen, newLen}
	}

	newcap := nextslicecap(newLen, oldCap)

	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	// Specialize for common values of et.Size.
	// For 1 we don't need any division/multiplication.
	// For goarch.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
	// For powers of 2, use a variable shift.
	noscan := !et.Pointers()
	switch {
	case et.Size_ == 1:
		lenmem = uintptr(oldLen)
		newlenmem = uintptr(newLen)
		capmem = roundupsize(uintptr(newcap), noscan)
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.Size_ == goarch.PtrSize:
		lenmem = uintptr(oldLen) * goarch.PtrSize
		newlenmem = uintptr(newLen) * goarch.PtrSize
		capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
		overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
		newcap = int(capmem / goarch.PtrSize)
	case isPowerOfTwo(et.Size_):
		var shift uintptr
		if goarch.PtrSize == 8 {
			// Mask shift for better code generation.
			shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
		} else {
			shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
		}
		lenmem = uintptr(oldLen) << shift
		newlenmem = uintptr(newLen) << shift
		capmem = roundupsize(uintptr(newcap)<<shift, noscan)
		overflow = uintptr(newcap) > (maxAlloc >> shift)
		newcap = int(capmem >> shift)
		capmem = uintptr(newcap) << shift
	default:
		lenmem = uintptr(oldLen) * et.Size_
		newlenmem = uintptr(newLen) * et.Size_
		capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
		capmem = roundupsize(capmem, noscan)
		newcap = int(capmem / et.Size_)
		capmem = uintptr(newcap) * et.Size_
	}

[]byte (Size_ == 1), []*T (Size_ == PtrSize), and any slice of a power-of-two-sized type all get their own fast path. Everything else multiplies.

Then the allocation, the zeroing of the tail, and the copy:

go
// src/runtime/slice.go:259
	if overflow || capmem > maxAlloc {
		panic(errorString("growslice: len out of range"))
	}

	var p unsafe.Pointer
	if !et.Pointers() {
		p = mallocgc(capmem, nil, false)
		// The append() that calls growslice is going to overwrite from oldLen to newLen.
		// Only clear the part that will not be overwritten.
		// The reflect_growslice() that calls growslice will manually clear
		// the region not cleared here.
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
			// Only shade the pointers in oldPtr since we know the destination slice p
			// only contains nil pointers because it has been cleared during alloc.
			//
			// It's safe to pass a type to this function as an optimization because
			// from and to only ever refer to memory representing whole values of
			// type et. See the comment on bulkBarrierPreWrite.
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et)
		}
	}
	memmove(p, oldPtr, lenmem)

	return slice{p, newLen, newcap}
}

Three steps, in order:

  1. Allocate capmem bytes. For pointer-free types the memory is not zeroed (it is noscan, so the GC will never look at it) — but the region between newLen and cap is explicitly cleared anyway.
  2. Shade the old pointers for the write barrier, so the garbage collector knows the old array still holds references.
  3. memmove the old contents into the new array.

That memmove is the cost of every reallocation. It is why growing a slice in a loop is O(n) in total work, not O(n) in allocations only.

The growth curve

How much bigger should the new array be? This is nextslicecap:

go
// src/runtime/slice.go:325
// nextslicecap computes the next appropriate slice length.
func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		return newLen
	}

	const threshold = 256
	if oldCap < threshold {
		return doublecap
	}
	for {
		// Transition from growing 2x for small slices
		// to growing 1.25x for large slices. This formula
		// gives a smooth-ish transition between the two.
		newcap += (newcap + 3*threshold) >> 2

		// We need to check `newcap >= newLen` and whether `newcap` overflowed.
		// newLen is guaranteed to be larger than zero, hence
		// when newcap overflows then `uint(newcap) > uint(newLen)`.
		// This allows to check for both with the same comparison.
		if uint(newcap) >= uint(newLen) {
			break
		}
	}

	// Set newcap to the requested cap when
	// the newcap calculation overflowed.
	if newcap <= 0 {
		return newLen
	}
	return newcap
}

Three rules:

  • If the new length is more than double the old capacity, the new capacity is exactly the new length. No point over-allocating for a single huge append.
  • Below 256 elements, the capacity doubles.
  • At or above 256 elements, it grows by roughly 1.25x, via the newcap += (newcap + 3*threshold) >> 2 formula. That is newcap * 1.25 + 192, which is a smooth ramp from doubling into 1.25x rather than a cliff.

The point of the 1.25x regime is memory. Doubling a 10-million-element slice means temporarily needing 20 million elements of capacity; growing by 25% means the overhead of a reallocation is bounded at 25% of the live data instead of 100%.

Size classes

The requested capacity is then rounded up to a size class, so that the allocator does not have to deal with arbitrary sizes:

go
// src/runtime/msize.go:14
// Returns size of the memory block that mallocgc will allocate if you ask for the size,
// minus any inline space for metadata.
func roundupsize(size uintptr, noscan bool) (reqSize uintptr) {
	reqSize = size
	if reqSize <= maxSmallSize-gc.MallocHeaderSize {
		// Small object.
		if !noscan && reqSize > gc.MinSizeForMallocHeader { // !noscan && !heapBitsInSpan(reqSize)
			reqSize += gc.MallocHeaderSize
		}
		// (reqSize - size) is either mallocHeaderSize or 0. We need to subtract mallocHeaderSize
		// from the result if we have one, since mallocgc will add it back in.
		if reqSize <= gc.SmallSizeMax-8 {
			return uintptr(gc.SizeClassToSize[gc.SizeToSizeClass8[divRoundUp(reqSize, gc.SmallSizeDiv)]]) - (reqSize - size)
		}
		return uintptr(gc.SizeClassToSize[gc.SizeToSizeClass128[divRoundUp(reqSize-gc.SmallSizeMax, gc.LargeSizeDiv)]]) - (reqSize - size)
	}
	// Large object. Align reqSize up to the next page. Check for overflow.
	reqSize += pageSize - 1
	if reqSize < size {
		return size
	}
	return reqSize &^ (pageSize - 1)
}

Two tables: a fine-grained one for small objects (8-byte granularity up to 32 KB) and a coarse one above that (128-byte granularity up to 32 MB), then page alignment for anything larger. The noscan flag matters: a pointer-free object does not need the GC’s malloc header, so it can be packed tighter.

This is why cap of a freshly grown slice is often larger than you expect: you asked for 5 elements, nextslicecap said 8, and roundupsize said 64 bytes, so cap came back as 8 elements of 8 bytes each.

copy and overlap

The copy builtin lands here:

go
// src/runtime/slice.go:391
// slicecopy is used to copy from a string or slice of pointerless elements into a slice.
func slicecopy(toPtr unsafe.Pointer, toLen int, fromPtr unsafe.Pointer, fromLen int, width uintptr) int {
	if fromLen == 0 || toLen == 0 {
		return 0
	}

	n := fromLen
	if toLen < n {
		n = toLen
	}

	if width == 0 {
		return n
	}

	size := uintptr(n) * width
	if raceenabled {
		callerpc := sys.GetCallerPC()
		pc := abi.FuncPCABIInternal(slicecopy)
		racereadrangepc(fromPtr, size, callerpc, pc)
		racewriterangepc(toPtr, size, callerpc, pc)
	}
	if msanenabled {
		msanread(fromPtr, size)
		msanwrite(toPtr, size)
	}
	if asanenabled {
		asanread(fromPtr, size)
		asanwrite(toPtr, size)
	}

	if size == 1 { // common case worth about 2x to do here
		// TODO: is this still worth it with new memmove impl?
		*(*byte)(toPtr) = *(*byte)(fromPtr) // known to be a byte pointer
	} else {
		memmove(toPtr, fromPtr, size)
	}
	return n
}

It copies min(len(dst), len(src)) elements, and it uses memmove, so overlapping source and destination are handled correctly — copying forwards overlaps, unlike memcpy. That is why copy(s[1:], s) works the way you expect, and why append(s, s...) is legal.

But append has a subtlety of its own: append(s, s...) is specified to work even when the append reallocates, because the compiler copies the source into a temporary when it cannot prove the two do not overlap. That temporary costs an allocation, which is why append(s, s...) on a slice that needs to grow is more expensive than it looks.

The aliasing footguns

Now the part that bites. Because the backing array is shared, several common-looking patterns quietly share memory.

Sub-slicing shares the array.

go
buf := make([]byte, 16)
a := buf[0:4]
b := buf[8:12]
a[0] = 'x'   // b is unaffected, but buf[0] is 'x'
b[0] = 'y'   // buf[8] is 'y'

Sub-slicing with [:] keeps the same backing array and the same GC lifetime. A tiny sub-slice of a huge buffer keeps the whole buffer alive. That is what strings.Clone and bytes.Clone exist to fix.

The three-index slice is the escape hatch. s[:len(s):len(s)] sets the capacity equal to the length, so a later append is forced to allocate instead of writing into the shared tail. The slices package exposes it as Clip:

go
// src/slices/slices.go:433
// Clip removes unused capacity from the slice, returning s[:len(s):len(s)].
// The result preserves the nilness of s.
func Clip[S ~[]E, E any](s S) S {
	return s[:len(s):len(s)]
}

append may or may not mutate the original. If the slice has spare capacity, append writes into the shared array and the original slice sees the new element. If it does not, append allocates and the original is unchanged. The same code gives different results depending on capacity, which is the classic “works in the test, breaks in production” bug.

go
a := make([]int, 1, 4)
b := append(a, 2)
a[0] = 99
fmt.Println(b[0])  // 99: same backing array

c := make([]int, 1, 1)
d := append(c, 2)
c[0] = 99
fmt.Println(d[0])  // 2: separate backing array

Reflection can break the length invariant. reflect.Value.SetLen writes the length field directly, which is why it is checked against the capacity:

go
// src/reflect/value.go:2244
func (v Value) SetLen(n int) {
	v.mustBeAssignable()
	v.mustBe(Slice)
	s := (*unsafeheader.Slice)(v.ptr)
	if uint(n) > uint(s.Cap) {
		panic("reflect: slice length out of range in SetLen")
	}
	s.Len = n
}

slices.Grow and slices.Insert

The slices package gives you explicit control over capacity:

go
// src/slices/slices.go:417
// Grow increases the slice's capacity, if necessary, to guarantee space for
// another n elements. After Grow(n), at least n elements can be appended
// to the slice without another allocation. If n is negative or too large to
// allocate the memory, Grow panics.
// The result preserves the nilness of s.
func Grow[S ~[]E, E any](s S, n int) S {
	if n < 0 {
		panic("cannot be negative")
	}
	if n -= cap(s) - len(s); n > 0 {
		// This expression allocates only once (see test).
		s = append(s[:cap(s)], make([]E, n)...)[:len(s)]
	}
	return s
}

Grow is a single expression that allocates once: it extends the slice to its full capacity, appends n zero elements to force exactly one reallocation, and then trims the length back. The append inside goes through the normal growth path, so the new capacity lands on a size class rather than an exact number.

Insert shows the same “use append so we get a size class” thinking, and documents the shifting it performs:

go
// src/slices/slices.go:136
func Insert[S ~[]E, E any](s S, i int, v ...E) S {
	_ = s[i:] // bounds check

	m := len(v)
	if m == 0 {
		return s
	}
	n := len(s)
	if i == n {
		return append(s, v...)
	}
	if n+m > cap(s) {
		// Use append rather than make so that we bump the size of
		// the slice up to the next storage class.
		// This is what Grow does but we don't call Grow because
		// that might copy the values twice.
		s2 := append(s[:i], make(S, n+m-i)...)
		copy(s2[i:], v)
		copy(s2[i+m:], s[i:])
		return s2
	}
	s = s[:n+m]

and the comment that follows shows the shift it performs:

go
// src/slices/slices.go:159
	// before:
	// s: aaaaaaaabbbbccccccccdddd
	//            ^   ^       ^   ^
	//            i  i+m      n  n+m
	// after:
	// s: aaaaaaaavvvvbbbbcccccccc
	//            ^   ^       ^   ^
	//            i  i+m      n  n+m

The copy calls are memmove-based, so the shift is correct even though the destination overlaps the source.

Trade-offs

What the slice design buys:

  • O(1) append in the common case. No allocation, no copy, just a length bump — as long as there is spare capacity.
  • No refcounting. A slice is a plain pointer; copying a slice header is three machine words and never touches the GC.
  • Cheap sub-slicing. s[10:20] is three words of arithmetic, not a copy.
  • Bounded memory overhead. The 1.25x growth curve keeps the wasted capacity of a large slice to 25% rather than 100%.

What it costs:

  • Aliasing is the default. Every function that takes a slice can be surprised by a caller that shares the backing array.
  • Growth is O(n) in copying. Building a slice of a million elements one append at a time copies roughly a million elements’ worth of data in total.
  • cap is a promise, not a guarantee. Code that reads past len but within cap is undefined behaviour that the compiler will happily exploit.
  • The size class means cap is a lie. You asked for 5 elements and got a capacity of 8, because of the allocator’s size classes.

Caveats

A zero-length slice can still have a non-nil pointer. var s []int is nil, but s := []int{} is not, and s[:0] of a non-empty slice has a pointer too. Only == nil distinguishes them, and len and cap are both zero in every case.

append to a nil slice works and gives you a slice with capacity rounded to a size class. append(nil, x...) is a perfectly good way to build a slice from another slice.

Never reslice beyond cap. s[:cap(s)+1] panics with “slice bounds out of range”, but unsafe-based tricks to defeat it produce a slice whose “length” region contains uninitialised memory.

Passing a slice to a function does not copy it. The callee gets the same header; if the callee appends and the slice had spare capacity, the caller’s slice changes too.

A slice keeps its whole backing array alive. A one-element sub-slice of a 100 MB buffer keeps the 100 MB alive until the sub-slice dies. This is the single most common cause of “why is my program using so much memory” with slices.

copy copies element-wise, not bytes. copy(dst, src) where dst is []byte and src is []string does not compile, and the element type has to match exactly.

Wrapping up

The one thing to remember: a slice is a pointer plus a length plus a capacity, and the pointer is shared with everyone you gave the slice to. Everything else — the aliasing bugs, the surprising append behaviour, the memory retention — follows from that one fact.

The part people get wrong: the growth curve. Below 256 elements the capacity doubles; above it, it grows by about 1.25x, and then gets rounded up to an allocator size class. So cap after an append is rarely exactly what you would have guessed, and a loop of appends on a large slice does far less copying than a loop of appends on a small one.

What to go do about it: pre-size your slices with make([]T, 0, n) or slices.Grow when you know the size, use slices.Clip (or the s[:len(s):len(s)] form) when a callee must not write into your array, and remember that a sub-slice is a view, not a copy.

Further reading

  • src/runtime/slice.go — makeslice, makeslicecopy, growslice, nextslicecap, slicecopy and the moveSlice family.
  • src/runtime/msize.go — roundupsize, the bridge from a requested size to an allocator size class.
  • src/runtime/error.go — errorString, the runtime error type behind the makeslice and growslice panics.
  • src/slices/slices.go — Grow, Clip, Insert, Clone, Concat.
  • src/reflect/value.go — Value.Slice, Value.SetLen, Value.SetCap.
  • src/cmd/compile/internal/walk/convert.go — how the compiler rewrites []byte to string conversions.
  • src/cmd/compile/internal/walk/assign.go — how the compiler rewrites append into a call to growslice.

GoInternalsSlicesMemoryPerformance