When allocation becomes a bottleneck
You are writing a high-throughput service that processes thousands of requests per second. Every request needs a temporary buffer to parse incoming data. You allocate a new slice or struct for each call. The CPU starts spending more time in the garbage collector than in your actual business logic. Allocation pressure is choking the runtime. You need a way to reuse memory without writing a custom allocator from scratch.
What sync.Pool actually does
sync.Pool is a temporary storage locker for objects you plan to reuse. Think of it like a rental car lot. Instead of manufacturing a new car for every trip, you grab one from the lot, drive it, and return it when you are done. The lot manager keeps a few cars ready for immediate pickup. If the lot is empty, the manager calls the factory to build a new one.
The key difference with a rental lot is that the manager occasionally clears out old cars to make room for newer models. In Go, the garbage collector plays the role of the lot manager. It can discard pooled objects at any time during a collection cycle. You never get a guarantee that a pooled object will survive. You only get a performance optimization when the GC is idle. The pool trades durability for speed.
The minimal working example
Here is the simplest way to create and use a pool for byte slices.
package main
import (
"fmt"
"sync"
)
// slicePool stores reusable byte slices to avoid repeated allocations.
var slicePool = &sync.Pool{
// New runs only when the pool is empty and the GC has not evicted everything.
New: func() any {
// Allocate a fresh slice with a reasonable default capacity.
return make([]byte, 0, 1024)
},
}
func main() {
// Grab a slice from the pool or trigger New if the pool is empty.
buf := slicePool.Get().([]byte)
// Reset the length to zero while keeping the underlying capacity.
buf = buf[:0]
// Append data to demonstrate reuse without reallocation.
buf = append(buf, []byte("hello")...)
fmt.Println(string(buf))
// Return the slice to the pool for the next caller.
slicePool.Put(buf)
}
The type assertion .([]byte) is mandatory because Get() returns any. The compiler cannot verify the type at compile time. You must cast it back to the concrete type before using it.
Pools are fast because they avoid heap allocation. They are not magic. Trust the reset pattern.
How the runtime handles your objects
The runtime stores pooled objects in a per-P cache. Each logical processor (P) maintains its own local bucket. When you call Get(), the runtime checks the current P's bucket first. It is essentially a pointer swap. No locks are involved. If the local bucket is empty, the runtime checks other P buckets. If those are empty too, it calls your New function.
When you call Put(), the object goes back into the current P's bucket. The runtime does not track object lifetimes. It treats pooled objects as temporary scratch space. During a garbage collection cycle, the runtime can silently clear the entire pool. This is not a bug. It is a deliberate design choice that prevents memory leaks. If your pool grows too large, the GC shrinks it automatically.
You should design your code to handle an empty pool gracefully. Your New function is the fallback. It runs when the pool is dry. Keep New cheap. If New does heavy work, you defeat the purpose of pooling.
Convention aside: gofmt will automatically align the New function signature and indentation. Do not fight it. Let the tool decide the formatting. Most editors run it on save, so your code will match the standard library style without manual tweaking.
The pool is concurrent-safe but not thread-safe in the traditional sense. Multiple goroutines can call Get() and Put() simultaneously without data races. The runtime handles synchronization internally. You do not need to wrap calls in mutexes.
Pool objects are temporary. Design for eviction.
A realistic HTTP handler scenario
Here is how pooling looks in a production-style HTTP handler that reads request bodies.
package main
import (
"bytes"
"fmt"
"net/http"
"sync"
)
// bufferPool holds reusable byte buffers for reading request bodies.
var bufferPool = &sync.Pool{
// New creates a fresh buffer when the pool is empty.
New: func() any {
return &bytes.Buffer{}
},
}
// handleRequest demonstrates pooling in a concurrent HTTP handler.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Retrieve a buffer from the pool.
buf := bufferPool.Get().(*bytes.Buffer)
// Always reset the buffer before reuse to clear leftover data.
buf.Reset()
defer func() {
// Return the buffer to the pool after the handler finishes.
bufferPool.Put(buf)
}()
// Simulate reading data into the buffer.
buf.WriteString(r.URL.Path)
fmt.Fprint(w, "Processed: ", buf.String())
}
func main() {
http.HandleFunc("/", handleRequest)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
The defer statement ensures the buffer returns to the pool even if the handler panics. In tight loops, explicit Put() calls are preferable to avoid stack growth from deferred functions. The Reset() call is critical. Pooled objects retain their previous state. If you skip the reset, you leak data from the previous request.
Convention aside: receiver names should be one or two letters matching the type. If you attach a method to a pooled struct, use (b *Buffer) Reset() instead of (this *Buffer) or (self *Buffer). The standard library follows this pattern, and it keeps method signatures readable.
Never hold a pooled reference past the function scope. The pool will recycle the object while you still point to it. Race conditions happen fast.
Common traps and compiler warnings
Forgetting to reset state is the most common bug. Pooled objects are not zeroed between uses. If you store a pointer to a pooled struct and reuse it without clearing fields, you inherit stale data. The compiler will not warn you. You will get subtle logic errors that only appear under load.
Storing objects that hold OS resources is dangerous. File handles, network connections, and database transactions should not live in a pool. The pool will recycle the wrapper struct, but the underlying resource might not close properly. You will eventually exhaust file descriptors or connection limits.
Type assertion panics happen when New returns the wrong type or when you cast incorrectly. If New returns *bytes.Buffer but you assert to []byte, the program crashes at runtime with interface conversion: interface {} is *bytes.Buffer, not []byte. The compiler cannot catch this because sync.Pool uses any for storage.
Storing pointers to pooled objects across goroutine boundaries creates data races. If you pass a pooled pointer to a background goroutine and return the object to the pool immediately, the background goroutine will read or write to an object that another request is already using. The race detector will flag it. The fix is to copy the data out of the pooled object before handing it off.
The worst pool bug is the one that never logs. Always validate your reset logic under load.
When to reach for a pool
Use sync.Pool when you allocate and discard the same type of object thousands of times per second and GC pressure is measurable. Use a simple make() or new() when allocation is cheap or happens infrequently. Use a channel-based worker pool when you need to bound concurrency or coordinate tasks between goroutines. Use a custom ring buffer or object cache when you need guaranteed retention and strict lifecycle control. Use sync.Pool for short-lived, stateless objects that can be safely reset between uses.