AlgoMaster Logo

sync.Pool

Medium Priority16 min readUpdated June 6, 2026

A busy online store ends up allocating a lot of small, short-lived objects: a bytes.Buffer for every JSON response, a scratch struct for every receipt, a temporary slice for every cart calculation. Each allocation is cheap on its own, but at thousands of requests per second the garbage collector starts spending real CPU time tracing and freeing them. sync.Pool is Go's standard tool for recycling those objects across requests so the allocator can rest. It's a cache, not a guarantee, and that distinction shapes everything about how you use it.

The Problem: Allocation Pressure on the Hot Path

Consider the endpoint that returns an order's JSON to the customer's browser. It runs millions of times a day. Each call needs a buffer to serialize the response into.

The naive version allocates a fresh buffer every time.

One request, one buffer. The buffer goes out of scope, the GC collects it later. Now multiply by a million requests an hour. The runtime has to allocate, track, and free a million buffers it could have reused. Each allocation also fragments the heap and dirties cache lines that the next request would have hit cleanly.

The fix isn't to stop allocating. It's to keep a small stash of already-allocated buffers and hand them out instead. That stash is sync.Pool.

What sync.Pool Actually Is

A sync.Pool is a per-P cache of reusable objects. "P" here is the Go scheduler's notion of a logical processor. Roughly: each P has its own private slot plus a small local list, so Get and Put usually hit that private cache without contention. If a P's local cache is empty, the pool looks at a victim cache (objects left over from the previous GC cycle) and finally falls back to calling the pool's New function to build a fresh object.

Get walks left to right looking for an object. Put always stores into the current P's local cache. Each garbage collection cycle drains the local caches into the victim cache, and the next cycle drops the victim cache entirely. That two-step demotion is why a freshly-pooled object survives one GC but not two if nobody touches it.

The full API is small:

MethodWhat It Does
Get() anyReturn a pooled object, or call New() if the pool is empty.
Put(x any)Hand an object back to the pool.
New func() anyField on the Pool struct. Builds a fresh object when Get finds nothing.

You don't construct a pool with a function call. You declare it as a struct value and set New.

New is a function, not an object, so each call builds a fresh instance. If you forget to set New, Get returns nil when the pool is empty, and you have to handle that case yourself. Setting New is almost always what you want.

The Pooled Rewrite

Here's the JSON-encoding example again, this time pulling buffers from a pool and putting them back when done.

Three details matter in this code. First, bufPool.Get().(*bytes.Buffer) pulls a *bytes.Buffer out of the pool (or builds one via New if the pool was empty). The type assertion is needed because Get returns any. Second, buf.Reset() wipes whatever bytes might still be in there from a previous user. Third, defer bufPool.Put(buf) schedules the return-to-pool so it happens even if the encoder errors.

The copy at the end matters too. The byte slice returned by buf.Bytes() points at the buffer's internal storage, and we're about to put that buffer back in the pool. If we returned buf.Bytes() directly, the caller would be holding a pointer into a buffer that's about to be reused, which is one of the nastier bugs you can write with sync.Pool. Copying out makes the result independent of the pooled buffer.

What's Wrong With This Code

This is a real-world flavor of the most common sync.Pool bug: forgetting to reset.

Aman's receipt contains Riya's name and total at the top. The second call pulled the same buffer back out of the pool (a likely outcome when calls happen sequentially on the same P), but never cleared it. fmt.Fprintf appended Aman's lines after Riya's, so the receipt for Aman now leaks data from the previous customer.

The fix is one line.

Reset zeroes the buffer's length so the next write starts from byte zero. The backing array is kept, so no allocation happens. Follow "Get, then Reset, then defer Put" and this whole class of bug goes away.

A related variant of the same bug shows up with structs. Suppose you pool a struct that holds slices of items, and you Put it back without zeroing the slices. The next caller sees a struct with someone else's items already in the slice. Always define a Reset() method on any pooled struct that holds state, and call it at the top of every function that uses the pool.

Notice r.Items = r.Items[:0] instead of r.Items = nil. The first form keeps the slice's backing array so the next caller doesn't reallocate. The second form drops it, which defeats the point of pooling.

GC Interaction: The Pool Is a Cache, Not a Guarantee

The runtime can clear pooled objects at any garbage collection cycle. This is by design. sync.Pool exists to take pressure off the GC during steady traffic, not to keep objects alive forever. If your program goes idle and a GC runs, the pool may end up empty, and the next Get will call New.

Concretely, every GC cycle does two things to the pool: it moves the per-P local caches into the victim cache, and it drops whatever was in the victim cache before. An object pooled just before a GC survives one cycle in the victim cache. An object that nobody touches for two GCs is gone.

This GC behavior also means a sync.Pool is unhelpful in two failure modes:

  1. Burst then idle. A spike fills the pool with hundreds of buffers. The traffic dies down for a few seconds. The next GC clears them all, and the next spike has to rebuild from scratch. The pool only smooths sustained traffic.
  2. Wildly varying object sizes. If one caller grows a buffer to 10 MB before putting it back, the pool keeps that 10 MB buffer alive (until the next GC), and the next caller who pulls it gets a much bigger buffer than they wanted. The memory stays held until GC clears it.

When sync.Pool Helps, And When It Doesn't

The pool pays off on hot paths that allocate a lot of similarly-sized, short-lived objects. Outside of that shape, the cure is worse than the disease.

WorkloadVerdictWhy
JSON encoding a buffer per HTTP request, thousands of req/sHelps a lotHigh alloc rate, uniform size, short lifetime, classic fit.
Per-request scratch struct for receipt formattingHelpsSame shape, no shared state between requests once Reset is called.
Recycling a single 4 MB image-processing buffer used by 4 workersOften helpsBig allocs dominate the GC; recycling avoids the per-job malloc.
Cart total computed once per checkout, called dozens of times a dayDoesn't helpAllocation rate is so low the GC barely notices. Plain code is clearer.
Long-lived order objects kept in memory for the sessionHurtsPool is for transient objects. Long-lived data belongs in a real cache or a map.
Buffers with extreme size variance (256 B to 50 MB)HurtsThe pool keeps the giant outliers alive, and average memory use goes up.
Objects that hold sockets or file handlesDon't poolGC can drop them silently. Use a proper connection pool with explicit lifecycle.

If pprof shows runtime.mallocgc or runtime.scanobject near the top of your CPU profile, and the allocations come from objects with a clear "construct, use, throw away" lifecycle, sync.Pool is worth considering. If the profile shows something else dominating, look there first.

Common Bugs Beyond "Forgot to Reset"

A few more sharp edges show up often enough to cover explicitly.

Holding a Pointer After Put

Once you call Put, the object is the pool's property. Reading or writing through a pointer to it is undefined behavior, in the sense that you might see the next caller's data, your data, or a mix.

The fix is to read everything you need from the buffer before you put it back. Either copy the bytes out into a fresh slice (as we did earlier), or hand the buffer to the caller and let them decide when to return it. Don't put first and read after.

Storing Pointers Into Pooled Memory

A subtler variant: you Put a buffer, but some other data structure is still holding a slice that points inside the buffer's backing array. That slice now points into pooled memory, which means the next caller's writes will silently corrupt it.

What c.last ends up holding depends on what the pool did between the two Record calls. The output is undefined. The fix again is to copy: c.last = append([]byte(nil), buf.Bytes()...).

Pooling Across Goroutines That Mutate Concurrently

Get returns one object to one caller. Two goroutines calling Get get two different objects (or two fresh ones). What sync.Pool does not do is hand the same object to two callers and protect them with a mutex. If you Get an object, share it with another goroutine, and let both write to it, that's a data race, and the pool didn't cause it but doesn't prevent it either.

The pattern is one Get, one user, one Put. Treat the pooled object as exclusively owned for the lifetime between Get and Put.

Measuring the Win

You shouldn't pool by guess. The standard testing.B benchmark with the -benchmem flag tells you exactly how the alloc count per operation moves. Just enough to show the shape:

Drop this in a file named bench_test.go next to your code:

Run it with:

A typical result looks something like this (numbers vary by machine and Go version):

The interesting columns are the last two. B/op and allocs/op show that the pooled version allocates about half as much, half as often, per call. Whether the wall-clock saving (ns/op) is worth the pool's complexity depends on your traffic shape, but -benchmem is the honest way to find out before shipping.

If allocs/op doesn't drop after introducing the pool, something else in the function is doing the allocation, and the pool isn't helping. Look at the code with go build -gcflags="-m" (escape analysis) or run pprof on a live process to find where the allocations are coming from.

A Larger E-Commerce Example

Tying it together: a small handler-shaped function that builds a JSON order response using a pooled buffer and a pooled scratch struct. Pretend handleOrder is called once per HTTP request.

Each Get is followed immediately by Reset and a defer Put. The defers run in reverse declaration order, so the buffer goes back first, then the response struct. The output bytes are copied into a fresh slice before either Put fires. The Items slice is reset with [:0] so the backing array stays around for the next call.

This shape generalizes: one pool per object type, Get/Reset/defer Put at the top, copy out anything the caller will keep before the defers run.

Pool Lifetime and Scope

A sync.Pool should generally be a package-level variable, not a field on a struct that's created and dropped per request. Two reasons. First, the whole point of pooling is to share recycled objects across calls; a per-request pool defeats that. Second, allocating a pool per request adds its own allocation cost.

The common shape is a single package-level var fooPool = sync.Pool{...} per object type, often unexported, used by all the functions in that package. Library code that exposes pooled objects to callers tends to wrap Get and Put in helper functions to keep the type assertion and reset out of the caller's hands.

getBuf/putBuf give callers a clean API and centralize the Reset so individual functions can't forget. If you find yourself writing the same Get-then-Reset boilerplate at five call sites, wrap it.

One thing to avoid: don't combine a sync.Pool with a sized cap (max N objects). The pool already manages its size on your behalf via GC, and adding your own counter on top fights the runtime. If you really need bounded reuse with explicit lifecycle, write a channel-based pool or use a third-party package designed for it.

Pool Versus Map and Pool Versus Channel

Two common confusions about what sync.Pool is and isn't:

A sync.Pool is not a cache keyed by identity. You don't store "the parsed config for tenant X" in a pool, because Get can return any pooled object, not the one you stored. If you need lookup by key, that's a map (with sync.RWMutex or sync.Map).

A sync.Pool is also not a resource pool with explicit close. A database connection pool (*sql.DB) tracks its connections, has a maximum, knows how to close them, and survives across GC cycles. sync.Pool does none of that. Use the right primitive for the job.

You want...Use...
Recycle short-lived scratch objects to reduce GC pressuresync.Pool
Lookup by key with concurrent accessmap + sync.RWMutex or sync.Map
Bounded pool of expensive resources with explicit closeA custom channel-based pool or library
Connection pool for a database or HTTP clientThe library's own pool (e.g., *sql.DB, http.Transport)

The mental separation is: sync.Pool is for forgettable objects that the runtime is allowed to throw away whenever it wants. Anything that needs to stick around, or be addressable, or be closed properly, belongs somewhere else.

Quiz

sync.Pool Quiz

9 quizzes