Repeatedly allocating short-lived helper objects can become expensive in a hot path. A formatter may create temporary buffers for every request, an encoder may allocate scratch space for every record, or a parser may repeatedly construct helper objects that are discarded immediately after use.

Go’s sync.Pool can reuse some of those temporary objects across independent operations. That can reduce allocation work and garbage-collector pressure when the same kind of object is created frequently under load.

But a pool is not ordinary storage. An item placed in a sync.Pool may disappear at any time, and Get is allowed to behave as though the pool were empty. If your program requires an object to remain available, sync.Pool is the wrong abstraction.

The useful mental model is:

sync.Pool = opportunistic reuse of temporary objects
          != ownership, persistence, or a bounded cache

That distinction explains most of the API’s behavior and most of its pitfalls.

Start with the allocation problem

Consider a function that builds a short text record:

func formatRecord(id int, name string) string {
    var buf bytes.Buffer

    fmt.Fprintf(&buf, "id=%d name=%s", id, name)
    return buf.String()
}

This is simple and often completely adequate. The local bytes.Buffer has a clear lifetime, no shared state, and no cleanup protocol.

If this function runs in a very hot path, however, repeatedly growing temporary buffers may contribute meaningful allocation and garbage-collection work.

That is the kind of problem sync.Pool is designed to address: many independent callers repeatedly need temporary objects of the same general kind.

Do not introduce pooling merely because an allocation exists. First establish that allocation cost matters in the workload you care about.

Build the smallest useful pool

A sync.Pool can provide a new object when no reusable object is available:

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

A caller retrieves a value with Get and returns it with Put:

func formatRecord(id int, name string) string {
    buf := bufferPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufferPool.Put(buf)

    fmt.Fprintf(buf, "id=%d name=%s", id, name)
    return buf.String()
}

The type assertion is needed because Pool.Get returns any.

The important lifecycle is:

Get -> establish clean state -> use exclusively -> Put

The object belongs to the caller between Get and Put. Other goroutines should not use that same object during that interval unless the object itself is designed for concurrent use.

sync.Pool is safe for concurrent access. That does not automatically make the objects stored inside it safe for concurrent access.

Treat Get as permission to use an arbitrary reusable object

Get removes and returns an arbitrary item from the pool.

There are two consequences.

First, you must not depend on retrieval order. A pool is not a stack, queue, or keyed cache.

Second, Get may ignore previously stored items and act as though the pool were empty. If the pool has a New function, Get calls it when it would otherwise return nil.

So this assumption is incorrect:

bufferPool.Put(buf)

// Wrong mental model:
// the next Get must return buf.
same := bufferPool.Get()

The API does not provide that guarantee.

This is why pooled values should be interchangeable temporary helpers. Correctness must not depend on getting a particular object back.

A pooled item may disappear without notification

A pool is intentionally allowed to drop stored items at any time.

That behavior gives the runtime freedom to reclaim memory rather than forcing your process to retain every object ever returned to the pool.

It also means sync.Pool cannot be used to store required state:

type Session struct {
    UserID string
}

// Do not use sync.Pool as a session store.

If losing an item would break correctness, choose an abstraction with explicit ownership and lifetime rules, such as a map protected by appropriate synchronization, a database, or another durable store.

A useful test is:

If the pool were empty on every call to Get, would the program still be correct?

For a valid sync.Pool use, the answer should be yes. Performance may change, but behavior should remain correct.

Reset state before another caller can observe it

Reusing objects means reusing their previous state unless you clear it.

With bytes.Buffer, call Reset before writing new data:

buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)

Without the reset, the next operation could append to data left by a previous operation.

For a custom object, reset every field whose old value must not survive:

type Scratch struct {
    IDs   []int
    Label string
}

func (s *Scratch) reset() {
    s.IDs = s.IDs[:0]
    s.Label = ""
}

Then use the same lifecycle consistently:

scratch := scratchPool.Get().(*Scratch)
scratch.reset()
defer scratchPool.Put(scratch)

Resetting immediately after Get has a practical advantage: the caller establishes the clean-state invariant before doing any work.

Some codebases instead reset immediately before Put. That can also work, but every exit path must then guarantee that cleanup occurs. Whichever convention you use, make it explicit and consistent.

Do not return an object while references to its contents escape

Pooling becomes unsafe when a caller keeps a reference into an object that has already been returned for reuse.

Consider a pooled buffer:

buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()

buf.WriteString("result")
data := buf.Bytes()

bufferPool.Put(buf)

// data may still refer to buf's underlying storage.

Bytes returns a slice referring to the buffer’s underlying data. Once the buffer is put back, another caller may reset and overwrite that storage.

Returning or retaining data after the Put can therefore expose later mutations.

If data must outlive the pooled object, copy it before returning the object:

buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)

buf.WriteString("result")

result := append([]byte(nil), buf.Bytes()...)
return result

The copy creates independent storage with its own lifetime.

The same rule applies to slices, pointers, maps, and other references into pooled state: do not let them outlive the exclusive-use period unless they have been detached safely.

Returning a string from bytes.Buffer is different

bytes.Buffer.String converts the unread buffer contents to a string.

A returned string does not expose a mutable byte slice that callers can use to modify the buffer. Code can therefore commonly build a string and then return the buffer to a pool:

func formatRecord(id int, name string) string {
    buf := bufferPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufferPool.Put(buf)

    fmt.Fprintf(buf, "id=%d name=%s", id, name)
    return buf.String()
}

Still, do not generalize this behavior to every API that returns data derived from a pooled object. Check whether the returned value aliases the object’s internal storage.

Aliasing is the real question, not simply whether the return type looks immutable or convenient.

Avoid retaining unexpectedly large buffers

Pooling can reduce allocations, but it can also keep large backing arrays reachable longer than useful.

Suppose normal requests need a few kilobytes, while one rare request grows a buffer to tens of megabytes. Returning that unusually large buffer to the pool may retain much more memory than typical calls need.

A common policy is to pool only objects below a chosen capacity:

const maxPooledCapacity = 64 << 10 // 64 KiB

buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()

// use buf

if buf.Cap() <= maxPooledCapacity {
    bufferPool.Put(buf)
}

The exact threshold is workload-specific. It should come from measurement, not from a universal rule.

This trade-off illustrates an important point: fewer allocations do not automatically mean lower memory usage. Reuse can increase retained capacity.

Use pointers for pooled objects

The standard library documentation recommends that a pool’s New function generally return pointer types.

A typical declaration is:

var scratchPool = sync.Pool{
    New: func() any {
        return &Scratch{}
    },
}

Using pointers avoids copying the pooled object’s value in and out of the interface and makes the ownership interval clearer: one caller receives the object, mutates it, then returns the same object.

It also avoids accidentally pooling large value copies instead of the reusable allocation you intended to preserve.

Put only values that are safe to reuse

A pool should contain objects in a state future callers know how to handle.

Be cautious with values that contain:

  • open files or sockets;
  • active timers or goroutines;
  • locks currently held;
  • references to request-specific data;
  • buffers containing sensitive material;
  • objects with complicated external ownership.

sync.Pool does not run a cleanup hook when it discards an item. Therefore it is a poor place for resources that must be explicitly closed or released.

For such resources, use explicit lifecycle management.

The best pooled objects are usually plain temporary memory holders whose reuse does not require external cleanup.

Sensitive data may need explicit clearing

Resetting a buffer often changes its length without overwriting every byte in its backing array.

That is fine for ordinary temporary data, but it may be inappropriate for secrets or other sensitive material if your threat model requires old bytes to be erased promptly.

For example, repeatedly pooling buffers that once held credentials can keep those bytes in allocated memory even though the logical buffer is empty.

If sensitive data is involved, decide whether pooling is appropriate at all. When explicit overwriting is required, perform it deliberately and understand that compiler/runtime behavior and the broader memory model can make secure erasure a specialized concern.

Do not assume Reset means “overwrite all previous bytes.”

sync.Pool is not a bounded resource pool

The word “pool” is used for several different abstractions.

A database connection pool, for example, typically limits a scarce resource and may block callers until a resource becomes available.

sync.Pool does not provide those semantics.

It does not guarantee:

  • a maximum number of objects;
  • a minimum number of retained objects;
  • fairness;
  • waiting for an item;
  • stable membership;
  • retrieval of the object most recently returned.

If you need bounded concurrency or ownership of scarce resources, use a semaphore, channel, dedicated connection pool, or another abstraction designed for that contract.

sync.Pool specifically targets opportunistic reuse of temporary objects.

Do not copy a Pool after first use

A sync.Pool must not be copied after it has been used.

Prefer storing it as a stable variable or as a field in an object that itself is not copied:

type Encoder struct {
    buffers sync.Pool
}

If instances of Encoder may be copied by value after use, that design is risky because the embedded pool would be copied too.

Using pointer receivers and avoiding value copies for types containing synchronization primitives makes the ownership model clearer.

This same general caution applies to several types in package sync.

Pool operations provide a specific synchronization guarantee

sync.Pool is safe for multiple goroutines, and the API defines a memory-ordering relationship for actual reuse.

If Put(x) happens and a later Get returns that same x, the Put synchronizes before that Get.

Similarly, if New creates x and Get returns it, the creation synchronizes before the Get.

This means initialization and state changes performed before returning an object to the pool are visible when another goroutine later receives that same object.

That guarantee does not give two goroutines permission to use the same pooled object simultaneously. The normal ownership discipline still matters: retrieve, use exclusively, then return.

Measure whether pooling actually helps

Pooling adds code, state-reset requirements, aliasing risks, memory-retention decisions, and another performance mechanism developers must understand.

That complexity should buy something measurable.

Compare a straightforward version:

func encode(v Value) []byte {
    var buf bytes.Buffer
    // encode v
    return append([]byte(nil), buf.Bytes()...)
}

with a pooled version under a representative benchmark.

Useful measurements include:

  • allocations per operation;
  • bytes allocated per operation;
  • wall-clock throughput or latency;
  • peak and steady-state memory;
  • garbage-collection behavior under realistic concurrency.

A microbenchmark can show whether allocation count changes, but production-shaped measurements help reveal retained-memory costs and contention effects.

Do not assume pooling helps merely because it reuses objects.

When a simpler allocation is better

Prefer ordinary allocation when:

  • the code is not performance-sensitive;
  • the object is cheap to allocate;
  • calls are infrequent;
  • objects have complex cleanup requirements;
  • reused capacity would retain too much memory;
  • the pooling protocol makes ownership harder to reason about.

Modern Go allocation and garbage collection are designed to handle large amounts of short-lived data efficiently. A straightforward allocation is often easier to maintain and already fast enough.

sync.Pool is most valuable after profiling shows a repeated temporary allocation pattern worth optimizing.

Common mistakes

Using Pool as reliable storage

Items may disappear at any time. Required state belongs somewhere else.

Assuming Put determines the next Get

Retrieval is arbitrary, and Get may ignore stored items.

Forgetting to reset reused state

A pooled object can contain data from a previous caller. Establish a clean-state invariant before use.

Returning aliased data after Put

Slices or pointers into pooled storage can change once another caller reuses the object. Copy data that must escape.

Pooling resources that require Close

Discarded pool entries receive no cleanup callback. Explicitly managed resources need explicit lifecycle management.

Keeping every oversized object

Rare large allocations can inflate retained memory. Consider size-based admission to the pool.

Optimizing before measuring

A pool can complicate code without improving the workload that matters. Benchmark and profile first.

Use sync.Pool when reuse is optional

The defining property of sync.Pool is that reuse is opportunistic.

A good use case has all of these characteristics:

temporary objects
+ many independent callers
+ repeated allocation cost
+ objects are interchangeable
+ losing pooled items affects performance only

If those conditions hold, sync.Pool can be a useful optimization.

The safe workflow is simple: get an object, establish clean state, use it exclusively, ensure no aliases escape, then put it back only if it is still appropriate to retain.

Most importantly, keep correctness independent of the pool. If every Get had to allocate a fresh object tomorrow, the program should still behave exactly the same. Only its performance should change.