sync Pool for performance

Use sync.Pool to cache and reuse objects, reducing allocation overhead and garbage collection pressure.

The rental car lot for heap objects

You are running a JSON parser that processes millions of requests per second. Every request allocates a temporary buffer to hold the payload. The garbage collector wakes up, scans the heap, finds millions of short-lived buffers, and pauses your program to clean them up. Your latency spikes. The CPU graph shows a sawtooth pattern of allocation and collection. You do not need more memory. You need to stop throwing away objects you are about to use again.

Go gives you sync.Pool to solve this. It is a cache for temporary objects. It holds onto values you have finished with so you can reuse them later instead of allocating fresh memory. Think of it like a rental car lot. When you return a car, the company does not scrap it. They wash it, park it, and hand it to the next customer. The car exists between customers, but you only pay for the time you drive it. In Go, the car is a heap allocation. The lot is the pool. You grab an object, use it, and put it back. The next goroutine grabs the same object. The allocation happens once. The reuse happens forever.

sync.Pool trades memory for CPU cycles. You keep objects alive to avoid the cost of creating new ones.

How the pool works

A pool stores values of any type. It uses the any type (an alias for interface{}) to hold heterogeneous data. You define a pool with a New function that creates a fresh value when the pool is empty. You call Get to retrieve a value and Put to return it. The pool is safe for concurrent use. Multiple goroutines can call Get and Put simultaneously without locks.

Here is the simplest pool: define it, get an object, use it, put it back.

package main

import (
	"fmt"
	"sync"
)

// BufferPool caches byte slices to avoid repeated allocations.
var BufferPool = sync.Pool{
	// New runs only when the pool is empty.
	New: func() any {
		// Allocate a 1KB buffer.
		return make([]byte, 1024)
	},
}

func processRequest(data []byte) {
	// Get retrieves a buffer from the pool or calls New.
	buf := BufferPool.Get().([]byte)
	// Put the buffer back when the function returns.
	defer BufferPool.Put(buf)

	// Reset length to zero without shrinking capacity.
	buf = buf[:0]

	// Copy data into the buffer.
	_ = append(buf, data...)

	// Use the buffer.
	fmt.Println(len(buf))
}

The Get method checks the pool's internal storage. If a value is waiting, it hands it back immediately. If the pool is empty, it calls New to create a fresh value. The New function must return a non-nil value. If New returns nil, the program panics with sync: Pool.Get from nil New function. When you call Put, the value goes back into the pool for the next Get.

The implementation uses per-processor storage to minimize contention. Each logical processor has its own bucket. A goroutine running on processor A grabs from bucket A. It never fights with a goroutine on processor B. This design keeps the fast path lock-free. The pool also has a size limit per processor. If you put too many objects back, the pool discards the excess. You do not need to worry about the pool growing unbounded.

The pool is fast because it avoids locks on the happy path. Put the value back, or the memory leaks.

The between-GC lifecycle

sync.Pool is not a persistent cache. It is a temporary holding area. The garbage collector clears the pool during a collection cycle. This is a critical detail. The pool is most effective for objects that are created and destroyed within a single GC cycle. If your application has a steady stream of allocations, the pool stays warm. If your application allocates in bursts with long pauses, the pool might drain.

The design assumes high churn. You are optimizing for the case where you allocate, use, and discard an object faster than the GC runs. The pool is cleared on GC by design. This prevents holding onto memory that is no longer needed. If your workload changes, the pool adapts. You do not need to manually flush the pool. The runtime handles it. This makes sync.Pool safe for long-running servers. You can start pooling, and if the allocation pattern changes, the pool drains and New takes over. The pool is self-healing.

Convention aside: gofmt formats the struct literal for the pool. Do not argue about indentation. Let the tool decide. Most editors run gofmt on save, so the pool definition will look consistent across your codebase.

Realistic usage with structs

Pools shine when you reuse complex structs. Here is a realistic pattern: an HTTP handler that parses JSON into a reusable struct. The pool definition lives at package level. The New function allocates the struct and pre-sizes the map so json.Decode does not trigger reallocations inside the map.

package main

import (
	"encoding/json"
	"net/http"
	"sync"
)

// RequestPayload holds the data for a single API call.
type RequestPayload struct {
	UserID   int            `json:"user_id"`
	Action   string         `json:"action"`
	Metadata map[string]any `json:"metadata"`
}

// PayloadPool caches request structs to reduce GC pressure.
var PayloadPool = sync.Pool{
	// New creates a fresh struct with pre-allocated metadata map.
	New: func() any {
		return &RequestPayload{
			// Pre-allocate map to avoid growth during unmarshal.
			Metadata: make(map[string]any, 4),
		}
	},
}

The handler grabs a struct from the pool, resets it, unmarshals JSON, and returns the struct. The reset logic is mandatory. The pool hands out the same underlying object. If you do not reset the fields, the next user sees stale data.

// HandleAPI processes incoming JSON requests using pooled structs.
func HandleAPI(w http.ResponseWriter, r *http.Request) {
	// Get a struct from the pool.
	payload := PayloadPool.Get().(*RequestPayload)
	// Put the struct back when the handler returns.
	defer PayloadPool.Put(payload)

	// Reset fields to zero values before reuse.
	payload.UserID = 0
	payload.Action = ""
	// Clear the map without losing capacity.
	for k := range payload.Metadata {
		delete(payload.Metadata, k)
	}

	// Unmarshal JSON into the reused struct.
	if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	w.Write([]byte("ok"))
}

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In the handler, check the error immediately and return. Do not defer the error check.

Pitfalls and gotchas

Pools are easy to misuse. The biggest risk is state leakage. If you put a value back into the pool without resetting it, the next user gets dirty data. The compiler will not help here. You have to write the reset logic yourself. Another pitfall is the New function returning nil. If New returns nil, Get panics. The runtime throws a panic with sync: Pool.Get from nil New function. Always return a valid value.

Slices are dangerous in pools. A slice is a header containing a pointer, length, and capacity. When you pool a slice, you pool the header. The backing array is separate. If you use append on a pooled slice, Go might allocate a new backing array. The old backing array stays attached to the slice header in the pool. The next user gets a slice pointing to stale data. You must manage the capacity. Use buf = buf[:0] to reset length. Never use append if it triggers a reallocation. Check capacity before appending. Or use a fixed-size buffer and copy data manually.

Slices hide their backing array. Reset the length, check the capacity, and never trust append on pooled data.

Pointers create aliasing risks. If you pool a struct that holds a pointer to a map, and you put the struct back, the map stays alive. If you reset the struct but forget to clear the map, the next user sees the old map. Worse, if two goroutines grab the same pooled struct and both modify the map, they race. The pool hands out the same underlying object. You must ensure the object is fully independent after reset.

Pools also do not guarantee retention. The garbage collector can evict values from the pool between GC cycles. If you rely on the pool to hold a value across a long pause, you are out of luck. The pool is a best-effort cache. It might be empty when you call Get. Your code must handle the fresh allocation from New gracefully.

Reset the object before putting it back. The pool remembers nothing about your logic.

When to pool and when to allocate

Profiling is the only way to know if pooling helps. Allocation in Go is fast. Small objects are allocated in the thread-local cache. The cost of Get and Put might exceed the cost of make for tiny objects. Profile first. Use pprof to see if allocations are actually a bottleneck. If the GC is not pausing the program, pooling might add complexity without benefit.

Use sync.Pool when you allocate and discard the same type of object in a tight loop. Use sync.Pool when allocations dominate CPU time and GC pauses are visible. Use sync.Pool when you want zero-lock contention on the fast path and can tolerate values being evicted by the garbage collector. Use a fresh allocation when the object is small, the allocation rate is low, or the reset logic is more expensive than the allocation itself. Use a channel-based worker pool when you need to bound concurrency or manage a fixed set of long-lived resources. Use fresh allocation when profiling shows allocations are cheap.

Where to go next