sync Pool in Go to Cut Allocations and When It Backfires
Benchmark sync.Pool against make for 64 byte, 4 KB and 64 KB buffers: 4786 ns became 8.4 ns per use, parallel 6683 ns became 1 ns, and when it backfires

An allocation in Go is cheap and the garbage it creates is not. Every object allocated must eventually be found and freed by the collector, and a service that allocates large temporary buffers on every request pays for it in GC cycles and in latency. sync.Pool is the standard library's answer: a cache of reusable objects that you Get instead of allocating and Put back when done. It is simple, it is used throughout the standard library (in fmt, encoding/json and net/http), and it has some well known ways to go wrong. This article measures what a pool saves for buffers of three sizes and a small struct, serially and in parallel, then describes the cases where it hurts.
Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS, median of 5 runs of 200 ms each, RunParallel with 10 goroutines
It belongs with reducing allocations, allocation profiles (which tell you where a pool would help) and the strings Builder, whose Reset shows why a pool of Builders does not work. The cornerstone is the Go performance guide.
How a pool works
A sync.Pool has a New function that makes a fresh object when the pool is empty. Get returns a pooled object if there is one, otherwise calls New. Put returns an object to the pool. Internally each processor (P) has a private slot and a small queue, so Get and Put usually touch only the local processor's data with no locking, and idle objects can be stolen by other processors. At every garbage collection the pool's contents are moved to a victim cache and, one cycle later, dropped. A pool is therefore a cache that the runtime is free to empty, and you must never depend on an object being there.
The benchmark
For each of 64 bytes, 4096 bytes and 65536 bytes, four measurements: make each time and the pool, each serially and under RunParallel (10 goroutines). Then a 32 byte struct.
package main
import (
"fmt"
"sync"
"testing"
)
var sink []byte
var osink *obj
type obj struct{ a, b, c, d int64 }
func main() {
for _, size := range []int{64, 4096, 65536} {
pool := sync.Pool{New: func() any { b := make([]byte, size); return &b }}
fmt.Printf("-- buffers of %d bytes\n", size)
bench("make each time", func(b *testing.B) {
for b.Loop() {
buf := make([]byte, size)
buf[0] = 1
sink = buf
}
})
bench("sync.Pool Get and Put", func(b *testing.B) {
for b.Loop() {
p := pool.Get().(*[]byte)
(*p)[0] = 1
pool.Put(p)
}
})
bench("make each time, 10 goroutines", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
buf := make([]byte, size)
buf[0] = 1
sink = buf
}
})
})
bench("sync.Pool, 10 goroutines", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
p := pool.Get().(*[]byte)
(*p)[0] = 1
pool.Put(p)
}
})
})
}
// A 32 byte object: the allocator is already fast.
small := sync.Pool{New: func() any { return new(obj) }}
fmt.Println("-- a 32 byte struct")
bench("new(obj) each time", func(b *testing.B) {
for b.Loop() {
o := new(obj)
o.a = 1
osink = o
}
})
bench("sync.Pool Get and Put", func(b *testing.B) {
for b.Loop() {
o := small.Get().(*obj)
o.a = 1
small.Put(o)
}
})
}-- buffers of 64 bytes
make each time 15.2 ns/op 64 B/op 1 allocs/op
sync.Pool Get and Put 8.3 ns/op 0 B/op 0 allocs/op
make each time, 10 goroutines 32.0 ns/op 64 B/op 1 allocs/op
sync.Pool, 10 goroutines 1.2 ns/op 0 B/op 0 allocs/op
-- buffers of 4096 bytes
make each time 512.8 ns/op 4096 B/op 1 allocs/op
sync.Pool Get and Put 8.3 ns/op 0 B/op 0 allocs/op
make each time, 10 goroutines 655.6 ns/op 4096 B/op 1 allocs/op
sync.Pool, 10 goroutines 1.0 ns/op 0 B/op 0 allocs/op
-- buffers of 65536 bytes
make each time 4786.8 ns/op 65536 B/op 1 allocs/op
sync.Pool Get and Put 8.4 ns/op 0 B/op 0 allocs/op
make each time, 10 goroutines 6683.3 ns/op 65536 B/op 1 allocs/op
sync.Pool, 10 goroutines 1.0 ns/op 0 B/op 0 allocs/op
-- a 32 byte struct
new(obj) each time 14.1 ns/op 32 B/op 1 allocs/op
sync.Pool Get and Put 8.4 ns/op 0 B/op 0 allocs/oppackage main
import (
"flag"
"fmt"
"sort"
"testing"
)
func init() {
testing.Init()
flag.Set("test.benchtime", "200ms")
}
// bench runs f five times and prints the median, which damps the noise of a laptop.
func bench(name string, f func(b *testing.B)) {
const runs = 5
ns := make([]float64, 0, runs)
bs := make([]int64, 0, runs)
as := make([]int64, 0, runs)
for i := 0; i < runs; i++ {
r := testing.Benchmark(f)
ns = append(ns, float64(r.T.Nanoseconds())/float64(r.N))
bs = append(bs, r.AllocedBytesPerOp())
as = append(as, r.AllocsPerOp())
}
sort.Float64s(ns)
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
sort.Slice(as, func(i, j int) bool { return as[i] < as[j] })
fmt.Printf("%-38s %12.1f ns/op %9d B/op %6d allocs/op\n", name, ns[runs/2], bs[runs/2], as[runs/2])
}Results
| Buffer | make | Pool | make, 10 goroutines | Pool, 10 goroutines |
|---|---|---|---|---|
| 64 B | 15.2 ns, 64 B, 1 alloc | 8.3 ns, 0 alloc | 32.0 ns | 1.2 ns |
| 4 KB | 512.8 ns, 4096 B, 1 alloc | 8.3 ns, 0 alloc | 655.6 ns | 1.0 ns |
| 64 KB | 4,786.8 ns, 65,536 B, 1 alloc | 8.4 ns, 0 alloc | 6,683.3 ns | 1.0 ns |
| 32 B struct | 14.1 ns, 32 B, 1 alloc | 8.4 ns, 0 alloc | not measured | not measured |
(In the parallel columns ns/op is wall time divided by operations across all goroutines, so it is about the per-operation cost divided by the parallelism for the pool, and degrades when the allocator becomes a bottleneck.)
The saving grows with the size. A pool hit costs about 8 ns whatever the buffer, while make costs time proportional to the size, because the allocator must zero the memory: 513 ns for 4 KB and 4,787 ns for 64 KB. A 64 KB buffer from a pool is 570 times cheaper than a new one. This is the main reason fmt pools its print state and net/http pools its bufio readers and writers.
Parallel use favours the pool. make slowed down under 10 goroutines (655.6 ns for 4 KB against 512.8 ns alone, and 6,683 ns against 4,787 ns for 64 KB), because the allocator, the memory bandwidth and the collector are shared resources. The pool stayed at 1 ns per operation, which is about 10 ns per goroutine, the same as serial. That is the per processor design working.
Small objects: little to gain. The 64 byte buffer and 32 byte struct allocate in about 15 ns, and the pool hit took 8 ns, a saving of 6 to 7 ns per use. At that scale pooling saves almost nothing in time and some garbage, and adds complexity (a type assertion, a reset, a Put on every path). Pool large or expensive to initialise objects, not tiny ones.
Why the numbers flatter the pool
The benchmark runs for 200 ms on a nearly empty heap. A garbage collection almost never happens, so the pool is never cleared, and every Get after the first is a hit. A service behaves differently:
- GC clears the pool. Each collection moves pooled objects to a victim cache and drops them a cycle later. Right after a collection,
Getmisses and callsNew. For a service that collects every few seconds this is a small effect; for one that collects constantly it means the pool is often empty. - The pool's benefit is lower garbage, not just lower latency. The real win is fewer bytes for the collector to deal with. Measure it in a service: GC CPU share, allocation rate and p99 latency before and after (allocation profiles, GC pauses).
- A sequential benchmark never contends. Production traffic does, and that is where per processor caches help.
Do not trust a microbenchmark of a pool as the reason to add one. Use it to confirm that the pool is not slower, and use the allocation profile to decide that the allocation is the problem.
How a pool backfires
Putting back huge buffers. The pool does not limit the size of what it holds. A buffer that grew while serving one 20 MB response and was returned keeps 20 MB reserved for as long as the pool keeps it, and a handful of those across processors is real memory. The standard library guards against it: fmt does not return buffers larger than 64 KB to its pool. Do the same: if cap(buf) > maxKeep { return } before Put.
Not resetting. An object comes back in the state it was left. A buffer that still contains the last request's bytes is a data leak between users if any code reads beyond the length it writes, and a logic bug if it assumes empty. Reset in Get's caller (buf = buf[:0], b.Reset(), zero the struct) every time.
Using an object after Put. The next Get on another goroutine may receive it while you still hold a reference. This is a use after free in spirit, a data race that -race can report. Do not keep references after returning an object.
Pooling things that cannot be reused. A strings.Builder drops its buffer on Reset, so a pool of Builders pools nothing useful (the Builder article). Pool bytes.Buffer or []byte.
Pooling pointer rich objects. An object that points to other memory keeps that memory alive while in the pool, and the collector must scan it.
Treating it as a cache of values. A Pool is not a bounded cache: you cannot ask for a particular item, count them or evict. For connection pools use a real pool with limits (database connection pools).
Pooling when Put allocates. Putting a slice into a pool as a []byte converts it to an interface, which allocates a slice header. The lab pooled *[]byte (a pointer) for that reason. staticcheck flags it (SA6002).
Checklist
- Pool large buffers and expensive objects; skip objects under about 100 bytes.
- Reset on every
Get, and discard oversized objects beforePut. - Pool pointers (
*[]byte,*bytes.Buffer), not values that box onPut. - Confirm with an allocation profile that the allocation matters before adding one.
- Measure GC share and latency in a service, not only a microbenchmark.
- Never use an object after returning it.
The type is documented in the sync package and the design notes in the Go source for sync.Pool.


