The rental shop for temporary objects
You are building a high-throughput HTTP server. Every incoming request needs a buffer to parse JSON payloads. You allocate a fresh bytes.Buffer for each request, fill it, read the data, and let the reference drop. Under light load, the program runs smoothly. Under heavy load, the garbage collector wakes up constantly to sweep millions of short-lived allocations. Your latency spikes. The CPU spends more time cleaning up memory than processing actual requests.
sync.Pool solves this by acting as a rental shop for temporary objects. Instead of manufacturing a new tool for every job and throwing it away, you grab one from the shelf, use it, wipe it clean, and put it back. The next request grabs that same tool. The pool keeps a stash of ready-to-use objects so the runtime does not have to ask the operating system for fresh memory on every call.
The pool is not a permanent storage locker. The garbage collector can evict pooled objects at any time to free memory when the system is under pressure. Treat it as a temporary holding area for short-lived allocations, not a cache for long-term data.
Pool objects are cheap to grab and return. Never treat them like persistent state.
How the pool actually works
A sync.Pool maintains two layers of storage. Each logical processor in the Go runtime holds a local stash of pooled objects. When you call Get(), the runtime checks the local stash first. This path avoids locks entirely and runs in nanoseconds. If the local stash is empty, the runtime falls back to a global pool protected by a mutex. If both are empty, the pool calls the New function you provided to manufacture a fresh object.
When you call Put(), the object goes straight back into the local stash. The runtime does not validate the object. It does not run cleanup code. It simply slots the pointer into a slice and moves on.
The garbage collector interacts with the pool in a specific way. At the end of every GC cycle, the runtime clears all pooled objects. This design prevents memory leaks from objects that were accidentally left in the pool. It also means you cannot rely on a pooled object surviving across multiple requests or function calls. The pool is optimized for allocation reuse, not data persistence.
Go conventions dictate that the New function returns any. The pool is generic by design because it stores pointers to arbitrary types. You will always need a type assertion when retrieving an object. The community accepts this small verbosity because it keeps the pool implementation simple and lock-free for the fast path.
Pool memory is temporary. Reset state before returning. Trust the GC to clear what you forget.
The minimal pattern
Here is the simplest way to set up a pool and cycle an object through it.
package main
import (
"fmt"
"sync"
)
// BufferPool holds reusable byte slices for temporary parsing work.
var BufferPool = sync.Pool{
// New runs only when the pool is completely empty.
New: func() any {
// Allocate a fresh slice. The capacity stays fixed for reuse.
return make([]byte, 0, 1024)
},
}
func main() {
// Grab a slice from the pool. The type assertion is safe because New always returns []byte.
buf := BufferPool.Get().([]byte)
// Reset length to zero. Capacity remains intact for the next user.
buf = buf[:0]
// Simulate parsing work by appending data.
buf = append(buf, []byte("hello")...)
fmt.Println(string(buf))
// Return the slice to the pool. The next Get() will reuse this exact allocation.
BufferPool.Put(buf)
}
The code demonstrates the core loop: grab, reset, use, return. The New function only fires when the pool has nothing to give. The type assertion on Get() is a deliberate trade-off. You pay a tiny runtime check to keep the pool implementation generic and fast.
Walking through the lifecycle
When your program starts, the pool is empty. The first Get() triggers the New function. The runtime allocates a []byte with length zero and capacity 1024. The pointer goes into the local per-processor stash.
You slice the buffer to length zero. This is critical. The capacity stays at 1024, but the length resets so the next user sees an empty slice. You append data, read it, and call Put(). The pointer slides back into the local stash.
A second goroutine calls Get(). The runtime finds the pointer in the local stash and returns it immediately. No lock. No allocation. The goroutine resets the length, appends new data, and returns it.
Eventually, the garbage collector runs. The runtime sweeps the pool and discards all stored pointers. The next Get() will trigger New() again. This eviction behavior is intentional. It guarantees that pooled objects cannot accidentally hold references to long-lived data and cause memory leaks.
The pool trades persistence for speed. You get allocation reuse without paying for locks or complex lifecycle management.
Pool reuse is fast. GC eviction is guaranteed. Design around both.
Real-world request handling
Here is how the pattern looks inside an HTTP handler that parses incoming request bodies.
package main
import (
"encoding/json"
"net/http"
"sync"
)
// ParseResult holds temporary data for a single request.
type ParseResult struct {
Body []byte
Tokens []string
IsValid bool
}
// ResultPool stores reusable parse result structs.
var ResultPool = sync.Pool{
// New manufactures a fresh struct when the pool is empty.
New: func() any {
// Pre-allocate the tokens slice to avoid growth during parsing.
return &ParseResult{
Tokens: make([]string, 0, 16),
}
},
}
// reset clears all fields so the next user gets a clean slate.
func (r *ParseResult) reset() {
// Zero out the body slice length. Capacity stays for reuse.
r.Body = r.Body[:0]
// Clear the tokens slice. Capacity remains at 16.
r.Tokens = r.Tokens[:0]
// Reset the boolean flag.
r.IsValid = false
}
// handleRequest demonstrates the pool lifecycle in a web handler.
func handleRequest(w http.ResponseWriter, req *http.Request) {
// Grab a reusable struct from the pool.
res := ResultPool.Get().(*ParseResult)
// Always reset immediately after Get to prevent data leakage.
res.reset()
// Simulate reading the request body into the pooled buffer.
res.Body = append(res.Body, []byte(`{"status":"ok"}`)...)
// Parse JSON into the pooled struct.
if err := json.Unmarshal(res.Body, &res); err == nil {
res.IsValid = true
}
// Use the data.
if res.IsValid {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusBadRequest)
}
// Return the struct to the pool for the next request.
ResultPool.Put(res)
}
The handler grabs a struct, resets it immediately, does work, and returns it. The reset() method is the most important part of this pattern. If you skip it, the next request will see leftover data from the previous one. The compiler will not catch this mistake. You will get subtle data corruption that only appears under load.
Go conventions favor explicit reset methods over relying on zero-values. Pooled objects carry state. You must clear it. The reset() method belongs on the struct itself, keeping the pool usage clean and predictable.
Pool objects carry baggage. Wipe it clean before handing them back.
Where things go wrong
The most common mistake is forgetting to reset state before calling Put(). A pooled struct might hold a slice with leftover elements, a map with stale keys, or a boolean flag from a previous request. The next Get() returns that contaminated object. Your program behaves correctly in tests but corrupts data in production.
Another pitfall is assuming Get() always returns a pooled object. The garbage collector might have cleared the pool between calls. Your code must handle the case where New() just ran. This is why the reset step happens immediately after Get(), not before Put().
Type assertion panics happen when the pool is misconfigured. If you accidentally put a different type into the pool, the next Get() will crash with interface conversion: interface {} is *WrongType, not *ParseResult. The compiler cannot check pool contents at compile time. You must ensure every Put() matches the New() return type.
Storing objects with unexported fields that hold references to long-lived data causes memory leaks. If a pooled struct contains a pointer to a large database connection or a global configuration map, the GC cannot collect that data even if it evicts the pool. The reference keeps the memory alive. Keep pooled objects self-contained. Do not let them point outside their own allocation.
The compiler rejects programs where you forget to capture loop variables in closures, but it stays silent about pooled state leakage. Runtime panics and silent corruption are the only warnings. Always reset immediately. Never store external references. Test under load.
Pool bugs hide in plain sight. Reset early. Isolate state. Verify under pressure.
When to reach for the pool
Use sync.Pool when you allocate and discard the same type of object thousands of times per second. Use a standard make() or new() allocation when the object is created infrequently or holds long-lived state. Use a sync.Mutex protected slice when you need guaranteed persistence and deterministic cleanup. Use a dedicated connection pool library when you are managing network sockets or database handles that require explicit lifecycle management.
Pool objects are temporary. Reset state before returning. Trust the GC to clear what you forget.