Every other Go performance post I read suggests using sync.Pool as if it were a magic “reduce allocations” button. In certain narrow cases it is, but I’ve seen so many people reach for it for the wrong reasons that I wanted to write this down.

The first and most important thing to know about sync.Pool: it is a cache that the garbage collector can empty at any time. From the docs, “any item stored in the Pool may be removed automatically at any time without notification.” In practice, this means every GC cycle, sync.Pool drops (most of) its contents. If you think you’re pooling long-lived objects, you’re not. You’re pooling objects across a single GC epoch.

This is not a bug; it’s the correct behavior for a cache that must not retain memory unnecessarily. But it shapes when sync.Pool is and isn’t useful.

sync.Pool helps when:

  1. You allocate an object on a very hot path
  2. You use it briefly and drop it
  3. The allocation rate is high enough that many allocations happen between GC cycles
  4. The object is large enough that heap churn actually matters
  5. You remember to reset the object’s state when you put it back

sync.Pool does not help when:

  1. You’re pooling tiny objects — the pool bookkeeping eats the win
  2. The object escapes somewhere you didn’t intend, and Put never gets called
  3. You use the pool across GC-crossing boundaries (every cycle, you start from scratch)
  4. The object has references to other allocations that also would have allocated anyway

Here’s a reasonable use case — a buffer pool for encoding protobufs:

var bufPool = sync.Pool{
    New: func() any {
        b := make([]byte, 0, 4096)
        return &b
    },
}

func Encode(msg proto.Message) ([]byte, error) {
    bp := bufPool.Get().(*[]byte)
    b := (*bp)[:0]
    defer func() {
        *bp = b[:0]
        bufPool.Put(bp)
    }()

    // ... marshal into b ...

    // but now we have to return something — and we can't return b,
    // because the caller will free it into the pool
    out := make([]byte, len(b))
    copy(out, b)
    return out, nil
}

Look at what happened there. I still allocated! Because the buffer needs to outlive the function call, I had to copy it into an owned slice. In this case, sync.Pool saved me nothing — I allocated a 4KB buffer from the pool, used it, then allocated a right-sized buffer to return, and put the big one back. The only allocation the pool saved was the 4KB buffer. If my encoded message is similarly sized, the pool saved 50% of the work. If the message is tiny, the pool saved basically nothing and cost me a sync.Pool ping-pong.

There’s a common idiom with bytes.Buffer that dodges this:

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func Write(w io.Writer, msg Message) error {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)
    // write into buf
    json.NewEncoder(buf).Encode(msg)
    _, err := w.Write(buf.Bytes())
    return err
}

Here the buffer is fully internal. It never escapes the function. Whatever we wrote to w got consumed by the time we return. The pool actually saves work here.

The other thing to understand is that sync.Pool is per-P internally (it uses a poolLocal per-GOMAXPROCS). That means you almost always get your own pool back without any cross-CPU synchronization. But! If a goroutine gets migrated between Ps, items can be “stolen” across Ps, and that involves an atomic. For high-throughput pools, you want your work to stay on one P as much as possible. That’s often out of your hands.

One subtle footgun: I once saw a pool of *bytes.Buffer where the buffer had grown to 64MB servicing a large request. It got put back in the pool. The next caller, doing a tiny request, pulled it out, wrote 200 bytes, and buf.Bytes() returned a 64MB backing buffer still alive in memory. The caller held onto the bytes. Now every big request was leaving 64MB buffers floating around until the next GC cycle cleared the pool.

The fix is to cap pooled items:

defer func() {
    if buf.Cap() < 1<<20 {
        bufPool.Put(buf)
    }
    // otherwise let it be GC'd
}()

Capping pooled items is not mentioned in the docs, and I’ve had to tell four different teams about this in the last year. If you pool growing things, cap them.

My current rule of thumb: before reaching for sync.Pool, run a benchmark with -benchmem and see if allocations are actually your bottleneck. If they are, pool carefully and measure the improvement. And if you’re pooling something that escapes the function that Gets it, you’re probably doing it wrong.

I wrote a bit more about allocation hunting in the pprof graph post — the same tooling for CPU works for allocations.