# Go Perf Sync Pool

> Guides sync.Pool as a GC-pressure reliever — per-P local pools with a lock-free fast path, the victim cache (a pooled item survives one GC), why items may vanish anytime so a Pool holds only fungible scratch, the Reset discipline, the grown-buffer retention leak (cap-check before Put), and putting a pointer not a value to avoid boxing into any. Auto-invokes on "use a sync.Pool", "pool these buffers", "reduce allocations with pooling", "is sync.Pool worth it". Routes Pool copylock to go-sync-primitives, allocator cost to go-perf-allocator-internals.

- Skill: `ctoth/go-perf-sync-pool` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-sync-pool`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-sync-pool/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-sync-pool

---


# Go Performance: sync.Pool

> "Pool's purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector."
> — [pkg.go.dev/sync#Pool](https://pkg.go.dev/sync#Pool)

> "Any item stored in the Pool may be removed automatically at any time without notification."
> — [pkg.go.dev/sync#Pool](https://pkg.go.dev/sync#Pool)

A `sync.Pool` is a free list the runtime owns: it recycles short-lived allocations so a hot path stops minting garbage the GC then has to scan and reclaim. The **correctness contract** — items are transient, the zero value is ready, and `A Pool must not be copied after first use` (`src/sync/pool.go:44`) so `go vet`'s `copylocks` fires on a by-value copy — is owned by **`go-sync-primitives`**; read it for the rules. This skill owns the **performance engineering**: the per-P internal structure that makes the fast path lock-free, the victim cache that decides whether a pooled item survives a GC, the pointer-vs-value `Put`, the grown-buffer retention leak, and the measurement that tells you whether pooling pays off at all. The allocator cost a Pool *relieves* is `go-perf-allocator-internals`; the GC it takes pressure off is `go-perf-gc-tuning`; deciding *whether* to do this by measurement is `go-perf-methodology`.

All Go below builds clean under `gofmt`, `go vet`, and `go test` on Go 1.25/1.26.

---

## 1. What a Pool Is For — and the One Thing It Must Hold

A Pool exists for exactly one job: "to cache allocated but unused items for later reuse, relieving pressure on the garbage collector. That is, it makes it easy to build efficient, thread-safe free lists" ([sync#Pool](https://pkg.go.dev/sync#Pool)). The canonical, *correct* use is fungible scratch space in a hot path — reusable `*bytes.Buffer` or `*[]byte` — and the standard library does exactly this: "An example of good use of a Pool is in the fmt package, which maintains a dynamically-sized store of temporary output buffers" (`src/sync/pool.go:34-37`).

The hard constraint that decides what a Pool may hold is its disappearance rule: **"Any item stored in the Pool may be removed automatically at any time without notification. If the Pool holds the only reference when this happens, the item might be deallocated"** (`src/sync/pool.go:18-20`). A Pool is therefore a *cache, never storage*. The items must be **fungible** — any one is as good as any other, because `Get` "selects an arbitrary item" and "Callers should not assume any relation between values passed to Pool.Put and the values returned by Get" (`src/sync/pool.go:124-128`).

- **Never pool anything that needs a `Close()` or owns a resource** (a connection, a file, a lock). The runtime drops items silently, so any cleanup you attached never runs. Connection pooling needs a real pool (e.g. `database/sql`), not `sync.Pool`.
- **Never use a Pool as a cache of distinct keyed values.** Every item is interchangeable scratch; there is no "get *this* one."

---

## 2. The Internal Structure — Why the Fast Path Is Lock-Free

A naive shared free list is a mutex-guarded slice, and under load that lock is the bottleneck a Pool exists to avoid. The runtime sidesteps it with a **per-P** structure (one slot per scheduler processor). The `Pool` struct holds `local` — a "local fixed-size per-P pool, actual type is `[P]poolLocal`" (`src/sync/pool.go:55`) — and each `poolLocal` is a `private any` "Can be used only by the respective P" plus a `shared poolChain` where the "Local P can pushHead/popHead; any P can popTail" (`src/sync/pool.go:68-71`).

That layout makes the common case lock-free. `Get` pins the goroutine to its P (`runtime_procPin`, which disables preemption — so no other goroutine touches this P's slot), then takes `private` with no synchronization at all; only if that is empty does it `popHead` the local shared chain, and only if *that* is empty does it `getSlow` and try to **steal** from other Ps' chains via `popTail` (`src/sync/pool.go:132-147`, `161-171`). `Put` is the mirror: it fills `private`, else `pushHead`s the local chain (`src/sync/pool.go:112-117`). This is why "A Pool is safe for use by multiple goroutines simultaneously" (`src/sync/pool.go:22`) without you holding any lock — the contention is sharded across Ps by construction.

Two consequences for how you use it:

- **A Pool only pays for itself under genuine concurrency and churn.** The per-P machinery (procPin, the shared chain, work-stealing) is overhead a single-goroutine or low-traffic path will never amortize (§7).
- **The runtime already cache-line-pads each `poolLocal`** — `pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte`, "Prevents false sharing" (`src/sync/pool.go:73-78`) — so you do not pad the Pool yourself; that battle is fought for you. (General false-sharing → `go-perf-false-sharing`.)

---

## 3. The Victim Cache — Surviving Exactly One GC

The single most important runtime fact for *using* a Pool well is what a GC does to it. Before Go 1.13 every GC emptied the Pool completely, so a heavy user saw an allocation spike on every cycle. Go 1.13 added the **victim cache**: "Pool no longer needs to be completely repopulated after every GC. It now retains some objects across GCs, as opposed to releasing all objects, reducing load spikes for heavy users of Pool" ([Go 1.13 release notes](https://go.dev/doc/go1.13#sync)).

The mechanism is `poolCleanup`, which "is called with the world stopped, at the beginning of a garbage collection" (`src/sync/pool.go:258-259`). It does two moves per GC (`src/sync/pool.go:265-281`):

1. **Drop the victim caches** from the previous cycle (`p.victim = nil`).
2. **Promote each primary cache to the victim cache** (`p.victim = p.local; p.local = nil`).

So an item you `Put` and then leave untouched lives in `local`; at the next GC it moves to `victim`; at the GC *after that* it is dropped. **A pooled item survives exactly one GC.** The practical rule: a `Get`/`Put` cycle whose churn outpaces the GC keeps the pool warm, because items are reclaimed from `local` into reuse before they age into `victim` and out. The victim is the cold tier — `getSlow` reaches it only "after attempting to steal from all primary caches because we want objects in the victim cache to age out if at all possible" (`src/sync/pool.go:173-175`).

This is why a Pool is *not* a memory leak you control and *not* a place to stash anything you expect to find later: across two quiet GCs, everything in it is gone. It is also why setting `GOGC` very low (frequent GCs) can defeat a Pool — items age out before they are reused (GC interaction → `go-perf-gc-tuning`).

---

## 4. `New`, and the Reset Discipline

Supply `New` so `Get` never returns `nil`: it "optionally specifies a function to generate a value when Get would otherwise return nil" (`src/sync/pool.go:61-64`). Without it, every caller must nil-check and construct by hand.

`Get` returns an item in **whatever state the previous user left it** — it is reused memory, not a fresh object. So you must **reset** it, and the discipline question is *where*:

- **Reset on Get** (right after acquiring) is the safest default: the user always starts clean regardless of how the item was returned. `bytes.Buffer` resets in O(1) via `buf.Reset()`.
- **Reset on Put** keeps the item small in the pool and means a borrower never sees stale bytes — but only if *every* return path calls `Put` with a reset item.

Forgetting the reset is a correctness bug that leaks one request's data into the next (owned as a contract by `go-sync-primitives`); here it is also the lever for §6.

```go
// RIGHT — New supplies a fresh item; Reset clears the previous user's bytes before reuse.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func render(w io.Writer, data []byte) error {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset() // start clean — never trust pooled state
	defer bufPool.Put(buf)
	buf.Write(data)
	_, err := w.Write(buf.Bytes())
	return err
}
```

---

## 5. Put a Pointer, Not a Value — Boxing Into `any` Allocates

Both `Put(x any)` and `New func() any` traffic in `any`. Storing a **non-pointer** value (a struct, a slice header, a `[N]byte` array) into an interface forces the runtime to allocate a heap copy to back the interface word — which **re-introduces the very allocation the Pool was meant to eliminate**, on every `Put`. Storing a **pointer** does not: the pointer fits in the interface directly, so no boxing allocation occurs. Staticcheck encodes this as a dedicated check: SA6002, "Storing non-pointer values in sync.Pool allocates memory" ([staticcheck SA6002](https://staticcheck.dev/docs/checks/#SA6002)).

The standard library always pools a pointer: `fmt` pools `*pp` (`src/fmt/print.go`), and `log` pools `*[]byte` — note the pointer to the slice, not the slice (`src/log/log.go:168-172`).

```go
// WRONG — pooling the slice value; every Put boxes the []byte header into `any`, allocating.
//
//	go vet (staticcheck): SA6002 argument should be pointer-like to avoid allocations
var bad = sync.Pool{New: func() any { return make([]byte, 0, 4096) }}

func badGet() []byte  { return bad.Get().([]byte) }
func badPut(b []byte) { bad.Put(b[:0]) } // boxing alloc here

// RIGHT — pool a *[]byte; the pointer goes into `any` with no allocation.
var good = sync.Pool{New: func() any { b := make([]byte, 0, 4096); return &b }}

func goodGet() *[]byte  { return good.Get().(*[]byte) }
func goodPut(p *[]byte) { *p = (*p)[:0]; good.Put(p) }
```

Confirm the win with `-benchmem`: a value-typed `Put` shows a stubborn `1 allocs/op` the pointer version drops to `0` (benchmark method → `go-perf-benchmarking-statistics`).

---

## 6. The Grown-Buffer Retention Leak — Cap-Check Before Put

The subtlest Pool bug inflates live heap instead of reducing it. If a borrower grows a pooled `*bytes.Buffer` to hold one 50 MB request and then `Put`s it back, that 50 MB capacity is now **pinned in the pool** — and worse, the per-P sharding means a handful of unlucky large requests can leave a giant buffer parked on several Ps, each held until two GCs age it out. The pool's job (steady, low memory) inverts.

This is not folklore — it is a bug the Go team fixed *in the standard library* and documented in the source. `fmt`'s pooled printer caps its buffer before returning it: "Proper usage of a sync.Pool requires each entry to have approximately the same memory cost. To obtain this property when the stored type contains a variably-sized buffer, we add a hard limit on the maximum buffer to place back in the pool. If the buffer is larger than the limit, we drop the buffer and recycle just the printer" — `if cap(p.buf) > 64*1024 { p.buf = nil }` (`src/fmt/print.go:156-164`, fixing [go.dev/issue/23199](https://go.dev/issue/23199)). `log` does the identical thing: `if cap(*p) > 64<<10 { *p = nil }` (`src/log/log.go:174-183`).

The rule: **a pooled buffer is only fungible if every entry has roughly the same cost.** Cap-check on the way in.

```go
// RIGHT — drop oversized buffers instead of pinning their capacity in the pool.
const maxPooled = 64 << 10 // keep entries ~uniform in cost; see go.dev/issue/23199

func putBuf(buf *bytes.Buffer) {
	if buf.Cap() > maxPooled {
		return // let the giant one be GC'd; the pool keeps only small, uniform buffers
	}
	buf.Reset()
	bufPool.Put(buf)
}
```

---

## 7. When a Pool Pays Off — and When It Costs You

A Pool is an optimization with a real downside, so it is gated on measurement, not reflex (the "should I" judgment is `go-perf-methodology`). It wins only when the relieved allocation churn outweighs the per-P machinery and the retained memory:

- **It pays off** on a high-throughput, concurrent path that allocates the *same fungible scratch* over and over — exactly `fmt`'s "store scales under load (when many goroutines are actively printing) and shrinks when quiescent" (`src/sync/pool.go:35-37`). The profile shows that object dominating `alloc_objects`/`alloc_space`.
- **It costs you** on a low-churn or low-concurrency path: the `Get`/`Reset`/`Put` plumbing and the procPin overhead can be *slower* than just letting the escape analyzer or a fresh `make` handle a rarely-allocated object — and the pool now holds memory the program would otherwise have freed. The docs say it directly: "a free list maintained as part of a short-lived object is not a suitable use for a Pool, since the overhead does not amortize well" (`src/sync/pool.go:39-42`).
- **A reused per-goroutine or per-request buffer is often simpler and faster** than a Pool when the lifetime is already scoped — no sharing, no GC-timing surprises. Reach for a Pool only when the scratch genuinely crosses independent concurrent callers.

Prove it both ways: `-benchmem` for `allocs/op` and a `benchstat` A/B for `ns/op` under `-cpu` parallelism. If pooling shows `~` against the no-pool baseline, you bought complexity and retained memory for nothing.

---

## 8. Don't

- **Don't pool anything that owns a resource or needs cleanup** — items "may be removed automatically at any time without notification" (`src/sync/pool.go:18`), so a `Close()` you rely on never runs. Use a real pool for connections.
- **Don't `Put` a non-pointer value** — boxing it into `any` allocates, re-creating the garbage you were eliminating (staticcheck [SA6002](https://staticcheck.dev/docs/checks/#SA6002)). Pool a `*T`.
- **Don't `Put` a buffer back without a cap-check** — an over-grown buffer pins its capacity in the pool and inflates live heap ([go.dev/issue/23199](https://go.dev/issue/23199); `src/fmt/print.go:156-164`).
- **Don't trust pooled state** — `Get` returns the previous user's leftovers; `Reset` before use (§4).
- **Don't assume items persist** — across two quiet GCs the victim cache is dropped and the Pool is empty (`src/sync/pool.go:265-281`).
- **Don't add a Pool on a low-churn path** — "the overhead does not amortize well" (`src/sync/pool.go:39-42`); measure first (`go-perf-methodology`).
- **Don't copy a Pool** — `copylocks` correctness, owned by `go-sync-primitives` (`src/sync/pool.go:44`).

---

## 9. Routing to Related Skills

**Correctness / idiom (sibling `golang` marketplace):**

- `go-sync-primitives` — the Pool **correctness contract**: items are transient, the zero value is ready, `copylocks`/no-copy, the Reset rule as a data-safety contract, and sync internals. Read it first; this skill is its performance depth.

**This plugin (`go-perf-*` depth):**

- `go-perf-allocator-internals` — the allocator cost a Pool *relieves*: size classes, the tiny allocator, scan-vs-no-scan spans, what "1 alloc" really costs.
- `go-perf-gc-tuning` — the GC a Pool takes pressure off; `GOGC`/`GOMEMLIMIT`, and how a low `GOGC` ages pooled items out via §3's victim cache.
- `go-perf-methodology` — the "is pooling worth it here" judgment: profile the allocation churn first, then A/B the change.
- `go-perf-benchmarking-statistics` — `-benchmem` for `allocs/op` and `benchstat` for the pointer-vs-value and pool-vs-no-pool A/B.
- `go-perf-false-sharing` — general cache-line padding (the Pool already pads its own per-P slots, §2).
- `go-perf-strings-bytes-zerocopy` — pooled `bytes.Buffer`/`*[]byte` as the backbone of zero-allocation string and byte work.

---

## 10. Reference Files

High-frequency `sync.Pool` 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)

