| name | go-perf-slices |
| description | Guides Go slice performance beyond correctness — the real growslice factors (2x while cap<256, then newcap += (newcap+768)>>2 ≈ 1.25x), preallocating via make([]T, 0, n) and slices.Grow to avoid repeated grow-and-copy, reusing buffers with b = b[:0], copy vs append, the GC-scan cost of []*T, slices.Clip, and a subslice pinning a huge backing array. Fires on "preallocate this slice", "why is append slow", "reduce slice allocations", "reuse this buffer". Routes aliasing to go-slices-and-maps, layout to go-perf-data-oriented-layout. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
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:
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 {
newcap += (newcap + 3*threshold) >> 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 covers newLen.
- After
nextslicecap, growslice calls roundupsize to a size class, so the observed cap is 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.
var out []Item
for _, r := range rows {
out = append(out, transform(r))
}
out := make([]Item, 0, len(rows))
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."
buf = slices.Grow(buf, len(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:
for _, job := range jobs {
buf := make([]byte, 0, 4096)
buf = render(buf, job)
w.Write(buf)
}
var buf []byte
for _, job := range jobs {
buf = buf[:0]
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.
func header(buf []byte) []byte { return buf[:64] }
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)>>2 then size-class rounding (§1) (slice.go).
- Don't re-
make a scratch slice each iteration — reset with s = s[:0] to keep the capacity and hit zero steady-state allocs (§4).
- Don't use
append where copy fits. A copy into a sized destination has no growth check and no realloc (§4).
- Don't reach for
[]*T by reflex on a large slice — pointer elements force per-cycle GC scanning that []T avoids (§5).
- Don't return a tiny sub-slice of a huge buffer and keep it long-lived —
slices.Clone the bytes you need so the big array frees (§6).
- Don't use
container/list for a hot queue — it allocates a node per element and boxes through an interface; a preallocated slice ring buffer or append/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-append mutation bug, three-index for safety, Clone/Clip for nil-vs-empty, "always store append's result."
go-perf-data-oriented-layout — []T vs []*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 when s[: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:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml