Go Performance: Slices
"If it has sufficient capacity, the destination is resliced to accommodate the new elements. If it does not, a new underlying array will be allocated." — builtin.append
A slice is a (ptr, len, cap) view over a backing array. The correctness of that view — aliasing, the conditional append mutation, three-index slicing to break sharing, Clone/Clip for nil-vs-empty — is owned by go-slices-and-maps; read it first if the question is "why did my caller's slice change?". This skill owns only the throughput and allocation layer: what growslice actually does, how to make it run once (or never) instead of N times, how to reuse capacity in a hot loop, and the GC cost of what you put in the slice.
All Go below builds clean under gofmt/go vet on Go 1.26.
1. The Growth Algorithm — Cite the Runtime, Not Folklore
When append runs out of capacity it calls runtime.growslice, which sizes the new backing array via nextslicecap and then rounds that up to an allocator size class. The folklore "slices grow 2×" is only half true. The authoritative code is src/runtime/slice.go:
// nextslicecap computes the next appropriate slice length. (src/runtime/slice.go)
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen // one big append jumps straight to newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap // < 256 elements: exactly 2x
}
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 // newcap += (newcap+768)>>2
if uint(newcap) >= uint(newLen) {
break
}
}
if newcap <= 0 {
return newLen
}
return newcap
}
Three facts that matter and that the "2×" myth gets wrong (slice.go):
- The threshold is on element count = 256, on
oldCap, not on bytes. - Above 256 the factor is not a clean 1.25×: it is
newcap + (newcap + 768) >> 2— ~1.25× plus a constant ~192-element bump, looped until it coversnewLen. - After
nextslicecap,growslicecallsroundupsizeto a size class, so the observedcapis rounded up again — don't assert an exact cap from the formula alone; the size class is the last word (slice.go lines 201–244).
Each regrow allocates a new array and memmoves the old contents (slice.go, memmove(p, oldPtr, lenmem)). Growing a slice from empty to N by repeated append therefore costs O(log N) allocations and copies total bytes ≈ several× N — pure churn the GC must then collect.
2. Preallocate When the Final Size Is Known
If you know (or can bound) the final length, make([]T, 0, n) reserves the backing array once and every append up to n is a cheap write — no regrow, no copy. Measured on this machine (Go 1.26, b.Loop, building a []int of 10 000 by append):
BenchmarkNoPrealloc-32 61366 ns/op 357628 B/op 19 allocs/op
BenchmarkPrealloc-32 24726 ns/op 81920 B/op 1 allocs/op
Preallocation turned 19 allocations into 1, cut bytes churned from ~358 KB to ~82 KB (the 82 KB is the single right-sized array; the 358 KB is the sum of all the intermediate arrays that were grown and thrown away), and ran ~2.5× faster. The win is the eliminated grow-and-copy, not the final allocation.
// WRONG — repeated growslice: ~19 allocs + copies for 10k elements
var out []Item
for _, r := range rows {
out = append(out, transform(r))
}
// RIGHT — one allocation, zero regrows
out := make([]Item, 0, len(rows)) // 0 length, len(rows) capacity
for _, r := range rows {
out = append(out, transform(r))
}
Watch the second argument: make([]T, n) gives length n (n zero values); appending then adds past them. For an append loop you almost always want make([]T, 0, n) (builtin.make).
3. slices.Grow — Extend Capacity Once on an Existing Slice
When you already hold a slice and are about to append a known number of elements, slices.Grow (Go 1.21) does the single regrow up front: "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" (slices.Grow). It "preserves the nilness of s."
// RIGHT — one grow, then N allocation-free appends
buf = slices.Grow(buf, len(more)) // ensure room for len(more) more
for _, v := range more {
buf = append(buf, process(v))
}
slices.Grow is the idiom when you can't pass capacity to make because the slice already exists (e.g. accumulating into a field or a reused buffer). It is the same regrow as append, just hoisted out of the loop and done exactly once.
4. Reuse Capacity in Hot Loops — b = b[:0], Not a Fresh make
Re-make-ing a scratch slice each iteration allocates each iteration. Resetting length to zero with s = s[:0] keeps the same backing array and capacity, so subsequent appends refill it allocation-free once it is warm:
// WRONG — a fresh allocation every iteration
for _, job := range jobs {
buf := make([]byte, 0, 4096) // allocates each pass
buf = render(buf, job)
w.Write(buf)
}
// RIGHT — reset length, keep capacity; steady state is zero allocs
var buf []byte
for _, job := range jobs {
buf = buf[:0] // len 0, cap preserved
buf = render(buf, job)
w.Write(buf)
}
s = s[:0] is the slice analogue of clear(m) for maps — reuse the allocation instead of churning it. (clear(s) zeroes elements but keeps len; s[:0] drops len to 0 and is what you want before refilling.) For a buffer shared across goroutines or call boundaries, pool it instead — go-perf-sync-pool.
copy vs append. When the destination already has the length, copy(dst, src) writes in place with no growth check and no reallocation — "the number of elements it copies is the minimum of the lengths of the two slices" (Mechanics of append). Use copy into a preallocated dst for fixed-size moves; reach for append only when the length is genuinely growing. copy "also gets things right when source and destination overlap," so it can shift elements within one slice (go.dev/blog/slices).
5. The Cost of a []*T — Pointer Slices Make GC Scan
A []int or []struct{...no pointers...} lands in a no-scan span: the garbage collector never walks its elements. A []*T, []string, [][]byte, or a slice of structs containing pointers must be scanned on every GC cycle — the collector visits each element looking for live pointers. growslice itself branches on this: pointer-free element types take the !et.Pointers() fast path (mallocgc(capmem, nil, false)), while pointerful ones need write barriers and a scannable allocation (slice.go lines 264–283).
The performance consequence: a large, long-lived []*Item adds proportional GC scan work for its whole lifetime, even when nothing changes. Where the data model allows, prefer []Item (values) over []*Item (pointers) for big slices — it removes the per-element scan and improves cache locality. (The deeper layout decision — struct-of-arrays vs array-of-structs, []T vs []*T — is owned by go-perf-data-oriented-layout; the allocator's scan/no-scan span mechanics by go-perf-allocator-internals.)
6. slices.Clip and Accidental Backing-Array Retention
Because re-slicing never copies, a small sub-slice can pin a huge backing array: the runtime keeps the whole array alive as long as any slice references it. This is a memory problem (the correctness/aliasing angle routes to go-slices-and-maps); the perf angle is heap bloat and the GC work of keeping megabytes live to serve kilobytes.
// WRONG — returns 64 bytes but pins the entire multi-MB read buffer
func header(buf []byte) []byte { return buf[:64] }
// RIGHT — copy out; the big array can be collected
func header(buf []byte) []byte { return slices.Clone(buf[:64]) }
slices.Clip solves the over-capacity variant: "Clip removes unused capacity from the slice, returning s[:len(s):len(s)]" (slices.Clip). Use it when you hand a slice to code that will append to it and you want that append to reallocate rather than scribble into capacity you still own — Clip sets cap == len so the next append must grow into a fresh array. It is the named form of the three-index slice s[lo:hi:max]: capping capacity isolates a downstream append from the shared backing array. Used as a perf tool, the three-index slice lets you hand out a window that cannot grow into the parent's storage — append isolation without a copy.
7. Don't
- Don't append in a loop without preallocating when the size is known — that is N regrows and N copies;
make([]T, 0, n)makes it one allocation (§2). - Don't claim "slices grow 2×." They grow 2× only below cap 256; above it the factor is
newcap += (newcap+768)>>2then size-class rounding (§1) (slice.go). - Don't re-
makea scratch slice each iteration — reset withs = s[:0]to keep the capacity and hit zero steady-state allocs (§4). - Don't use
appendwherecopyfits. Acopyinto a sized destination has no growth check and no realloc (§4). - Don't reach for
[]*Tby reflex on a large slice — pointer elements force per-cycle GC scanning that[]Tavoids (§5). - Don't return a tiny sub-slice of a huge buffer and keep it long-lived —
slices.Clonethe bytes you need so the big array frees (§6). - Don't use
container/listfor a hot queue — it allocates a node per element and boxes through an interface; a preallocated slice ring buffer orappend/s[:0]reuse is far cheaper (§4, go-perfbook). - Don't optimize a slice path you haven't measured — confirm the allocs with
-benchmem/a heap profile first (go-perf-methodology).
8. Routing to Related Skills
go-slices-and-maps— slice correctness: aliasing, the conditional-appendmutation bug, three-index for safety,Clone/Clipfor nil-vs-empty, "always store append's result."go-perf-data-oriented-layout—[]Tvs[]*T, struct-of-arrays vs array-of-structs, intrusive structures, cache-friendly access.go-perf-bounds-check-elimination— making the indexing in a slice loop drop its per-iteration bounds checks.go-perf-allocator-internals— size classes, scan vs no-scan spans, what one allocation truly costs.go-perf-sync-pool— pooling a reusable buffer across goroutines/calls whens[:0]reuse can't reach.go-perf-methodology— measure before and after;~from benchstat means the change bought nothing.
9. Reference Files
High-frequency slice performance anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml