# Go Perf Slices

> Guides Go slice performance beyond correctness — the real growslice factors (2x while cap less than 256, then newcap += (newcap+768) greater than greater than 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.

- Skill: `ctoth/go-perf-slices` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-slices`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-slices/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-slices

---


# 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](https://pkg.go.dev/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`](../../../golang/skills/go-slices-and-maps/SKILL.md)**; 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`:

```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](https://github.com/golang/go/blob/master/src/runtime/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](https://github.com/golang/go/blob/master/src/runtime/slice.go) lines 201–244).

Each regrow **allocates a new array and `memmove`s the old contents** ([slice.go](https://github.com/golang/go/blob/master/src/runtime/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.

```go
// 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](https://pkg.go.dev/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](https://pkg.go.dev/slices#Grow)). It "preserves the nilness of s."

```go
// 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:

```go
// 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`](../go-perf-sync-pool/SKILL.md)**.

**`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](https://go.dev/blog/slices)). 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](https://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](https://github.com/golang/go/blob/master/src/runtime/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`](../go-perf-data-oriented-layout/SKILL.md)**; the allocator's scan/no-scan span mechanics by **[`go-perf-allocator-internals`](../go-perf-allocator-internals/SKILL.md)**.)

---

## 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`](../../../golang/skills/go-slices-and-maps/SKILL.md)**); the perf angle is heap bloat and the GC work of keeping megabytes live to serve kilobytes.

```go
// 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](https://pkg.go.dev/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](https://github.com/golang/go/blob/master/src/runtime/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](https://github.com/dgryski/go-perfbook)).
- **Don't optimize a slice path you haven't measured** — confirm the allocs with `-benchmem`/a heap profile first (**[`go-perf-methodology`](../go-perf-methodology/SKILL.md)**).

---

## 8. Routing to Related Skills

- **[`go-slices-and-maps`](../../../golang/skills/go-slices-and-maps/SKILL.md)** — 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`](../go-perf-data-oriented-layout/SKILL.md)** — `[]T` vs `[]*T`, struct-of-arrays vs array-of-structs, intrusive structures, cache-friendly access.
- **[`go-perf-bounds-check-elimination`](../go-perf-bounds-check-elimination/SKILL.md)** — making the indexing in a slice loop drop its per-iteration bounds checks.
- **[`go-perf-allocator-internals`](../go-perf-allocator-internals/SKILL.md)** — size classes, scan vs no-scan spans, what one allocation truly costs.
- **[`go-perf-sync-pool`](../go-perf-sync-pool/SKILL.md)** — pooling a reusable buffer across goroutines/calls when `s[:0]` reuse can't reach.
- **[`go-perf-methodology`](../go-perf-methodology/SKILL.md)** — 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](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

