When sync.Pool helps in Go (and when it makes things worse)
Go’s garbage collector is fast, but it still has a cost. Every heap allocation is work the GC must track and eventually reclaim. In hot paths (encoding JSON, handling HTTP requests, building byte buffers) those allocations pile up. The sync.Pool type lets you reuse objects instead of allocating new ones. But it’s not always the right tool. Used carelessly, it can make your code slower, buggier, or just harder to maintain for no measurable benefit.
How sync.Pool works
A sync.Pool is a set of temporary objects that any goroutine can save to and retrieve from. You create one by providing a New function that allocates a fresh object when the pool is empty:
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
When you call Get(), the pool may return an object previously passed to Put(). Otherwise it calls New, if you configured one. When you’re done, you can call Put() to make the object available for reuse. There is no guarantee that a later Get() will return it.
Here’s the part people miss: objects in a pool may disappear at any time. You cannot use sync.Pool for data that must remain available. In the current runtime implementation, a garbage collection moves each pool’s primary per-P entries into a victim cache and drops the previous victim cache. That detail can improve reuse across one GC cycle, but it is not a persistence guarantee.
The current implementation maintains per-P (per-processor) storage to minimize contention. A goroutine checks its P’s private slot and shared list before looking at other Ps and the victim cache. This is useful context when reading the runtime source, but application code should rely on the documented Get and Put behavior rather than these internals.
When sync.Pool helps
Pooling wins when three conditions hold:
- You allocate frequently. The hot path creates many short-lived objects of the same type.
- The objects are expensive enough. Allocation + GC pressure is measurable in your benchmarks.
- The objects are resettable. You can cheaply return them to a clean state.
The standard library uses sync.Pool in these situations. fmt pools its internal printer state, encoding/json pools encoder state, and net/http pools several temporary values including buffered readers, writers, and copy buffers. The details vary, but the broad pattern is get, use, clean up, and put.
Here’s a realistic example, pooling bytes.Buffer for a handler that builds responses:
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func buildResponse(name string) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // Always reset before use
defer bufPool.Put(buf)
fmt.Fprintf(buf, "Hello, %s! Welcome back.", name)
return buf.String()
}
The buf.Reset() call is essential. Without it, this function would append to data left by a previous caller.
If you want more on managing shared state safely, the mutexes in Go post covers related synchronization patterns.
The reset pattern matters
Every type you pool needs a reliable reset. For bytes.Buffer, that’s Reset(). For a struct, restore every field that affects the next use. Missing one can leak state between requests, turning a performance optimization into a correctness or security bug.
A safe pattern is to define a Reset method on your pooled type:
type Request struct {
Headers map[string]string
Body []byte
status int
}
func (r *Request) Reset() {
for k := range r.Headers {
delete(r.Headers, k)
}
r.Body = r.Body[:0]
r.status = 0
}
var reqPool = sync.Pool{
New: func() any {
return &Request{
Headers: make(map[string]string, 8),
Body: make([]byte, 0, 1024),
}
},
}
func getRequest() *Request {
r := reqPool.Get().(*Request)
r.Reset()
return r
}
func putRequest(r *Request) {
reqPool.Put(r)
}
Notice how Body is sliced to zero length but keeps its backing array. That retained capacity is what makes reuse useful. If the bytes are sensitive, clear them before shortening the slice; if bodies can grow very large, also cap what you return to the pool.
When sync.Pool makes things worse
Here are the cases where pooling adds complexity for zero or negative gain.
Small, cheap objects. If you’re pooling a tiny value, the Get/Put work and extra lifecycle code may cost more than allocation. Measure before adding the pool.
Low-frequency paths. If a function runs only occasionally, there may be little opportunity to reuse an object before the runtime removes it. The saved work is unlikely to justify the extra code.
Objects with complex lifecycles. File handles, network connections, and other resources that require explicit cleanup need a lifecycle-aware owner. sync.Pool can remove an item without calling Close or any other cleanup hook. For database connections, use a dedicated pool such as database/sql instead.
Variable-size objects without caps. If one request grows a bytes.Buffer to 50 MB, returning it to the pool can retain that backing array until the pool drops it and the GC reclaims it. Cap what you return:
func putBuffer(buf *bytes.Buffer) {
// Don't pool buffers that grew too large
if buf.Cap() > 64*1024 {
return // Let GC reclaim it
}
buf.Reset()
bufPool.Put(buf)
}
Benchmarking pool performance correctly
A common mistake is writing a benchmark that shows huge wins for sync.Pool but doesn’t reflect real workloads. Here’s what to watch for.
Run with -benchmem. You want to see allocs/op drop. If allocations don’t change, the pool isn’t helping.
Match the real concurrency. Use b.RunParallel when the production path runs concurrently, but keep a sequential benchmark when that also represents real use. A benchmark should model the workload you care about.
Do not make GC tricks your headline result. Calling runtime.GC() inside the timed loop can dominate the benchmark. If you separately test a cold pool, label that experiment clearly; current Go may retain entries in a victim cache across one GC cycle.
Compare against the baseline. Benchmark plain allocation and the pooled version, then compare repeated runs with benchstat. If the difference is noise, skip the pool.
The decision checklist
Before adding sync.Pool to your code, ask:
- Is this path hot enough to show up in allocation profiles?
- Can I reset the object cheaply and completely?
- Is the object large enough that allocation cost matters?
- Can retained capacity grow, and if so, where should I cap it?
If the path is not measurably allocation-heavy or the object cannot be reset safely, skip the pool. A few extra allocations are usually preferable to a subtle data-leak bug from an improperly reset object.
sync.Pool is a specialized tool. When allocation pressure is real and measurable, it can reduce allocation volume and GC work. Without benchmark and profile evidence, it is extra code and extra risk with no demonstrated latency benefit.