Skip to content
gohacks.dev
Concurrency

sync Pool in Go and the Trap of Dirty Reused Objects

How sync.Pool works in Go, what it saves in allocations and time, the dirty object bug when you forget to reset, and when a pool is the wrong tool.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Every allocation in Go is eventually the garbage collector's problem, and in a service that handles tens of thousands of requests a second the cost of collecting the same short-lived buffers over and over can be a measurable share of CPU. sync.Pool is the standard library's answer: a thread-safe cache of reusable objects. It is also one of the few standard types where the right way to use it is surprising, and where the obvious way produces a data leak between requests.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This note explains how the pool behaves, measures what it buys, shows the dirty-object bug with a runnable example, and says when to leave it alone. It belongs to the concurrency material and relates to the garbage collector. Everything ran on Go 1.25.6.

How it works

A pool has a New function and two methods. Get takes an object from the pool if it has one, or calls New to make one. Put returns an object for later reuse. Internally the pool keeps per-processor caches so that Get and Put usually do not contend on a lock, and it participates in garbage collection: objects in a pool are freed over a couple of collection cycles, so the pool shrinks when load drops.

main.goGo
package main

import (
	"bytes"
	"fmt"
	"sync"
)

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

func render(name string) string {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset() // always reset on the way out
	defer bufPool.Put(buf)

	buf.WriteString("hello, ")
	buf.WriteString(name)
	return buf.String() // String copies, so the buffer can be reused
}

func main() {
	fmt.Println(render("ada"))
	fmt.Println(render("linus"))
}
Text
hello, ada
hello, linus

render borrows a buffer, resets it, uses it and puts it back. buf.String() returns a copy, so the caller's string does not alias the buffer's memory; this matters, because returning buf.Bytes() would hand out a slice into memory that the next Get overwrites.

The dirty object trap

The most common bug with pools is forgetting that Get returns an object in whatever state the last user left it. The pool does not clear anything:

main.goGo
package main

import (
	"bytes"
	"fmt"
	"sync"
)

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

func handle(user, secret string) string {
	buf := pool.Get().(*bytes.Buffer)
	defer pool.Put(buf)
	// BUG: no buf.Reset() here
	buf.WriteString("user=" + user)
	if secret != "" {
		buf.WriteString(" secret=" + secret)
	}
	return buf.String()
}

func main() {
	fmt.Println(handle("ada", "hunter2"))
	fmt.Println(handle("linus", ""))
}
Text
user=ada secret=hunter2
user=ada secret=hunter2user=linus

The second call prints user=ada secret=hunter2user=linus: the buffer still held the first request's data, and the second response includes another user's secret. In a real service the leak is intermittent, since it depends on which request gets which pooled buffer, which makes it hard to reproduce and a serious security bug. The rule is to reset on Get (or immediately before Put) as the first thing you do, every time, in the same helper so it cannot be skipped. A tidy way is to wrap the pool in two small functions, getBuf() which resets and putBuf() which also drops oversized buffers, so the pool type is never used raw.

For slices the equivalent is s = s[:0]; for structs, assign the zero value or reset each field. If an object has pointers inside, clear them as well, so the pooled object does not keep other data alive.

What it saves

A benchmark shows the effect on a buffer-heavy function. The two versions build a 4 KB payload; one allocates a fresh buffer each time, the other borrows from a pool:

pool_test.goGo
package main

import (
	"bytes"
	"sync"
	"testing"
)

var sink int

func fill(buf *bytes.Buffer) {
	for i := 0; i < 512; i++ {
		buf.WriteString("12345678")
	}
	sink = buf.Len()
}

func BenchmarkAllocate(b *testing.B) {
	for i := 0; i < b.N; i++ {
		buf := new(bytes.Buffer)
		fill(buf)
	}
}

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

func BenchmarkPooled(b *testing.B) {
	for i := 0; i < b.N; i++ {
		buf := pool.Get().(*bytes.Buffer)
		buf.Reset()
		fill(buf)
		pool.Put(buf)
	}
}

func BenchmarkAllocateParallel(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			buf := new(bytes.Buffer)
			fill(buf)
		}
	})
}

func BenchmarkPooledParallel(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			buf := pool.Get().(*bytes.Buffer)
			buf.Reset()
			fill(buf)
			pool.Put(buf)
		}
	})
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkAllocate-10            	  525031	      2297 ns/op	    8128 B/op	       7 allocs/op
BenchmarkAllocate-10            	  564828	      2299 ns/op	    8128 B/op	       7 allocs/op
BenchmarkAllocate-10            	  507600	      2217 ns/op	    8128 B/op	       7 allocs/op
BenchmarkPooled-10              	  912670	      1318 ns/op	       0 B/op	       0 allocs/op
BenchmarkPooled-10              	  916196	      1321 ns/op	       0 B/op	       0 allocs/op
BenchmarkPooled-10              	  916350	      1318 ns/op	       0 B/op	       0 allocs/op
BenchmarkAllocateParallel-10    	  570272	      2043 ns/op	    8128 B/op	       7 allocs/op
BenchmarkAllocateParallel-10    	  570722	      2077 ns/op	    8128 B/op	       7 allocs/op
BenchmarkAllocateParallel-10    	  578449	      2092 ns/op	    8128 B/op	       7 allocs/op
BenchmarkPooledParallel-10      	 5585979	       214.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkPooledParallel-10      	 5740742	       205.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkPooledParallel-10      	 4006075	       318.3 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	16.482s

On an Apple M1 Max the sequential allocating version took about 2,300 ns and made 7 allocations totalling 8,128 bytes per call, as the buffer grows from empty to 4 KB by doubling. The pooled version took about 1,320 ns and made none, because the buffer keeps its grown capacity. In the parallel runs the gap widened to about ten times: roughly 2,070 ns for allocation against 205 to 318 ns pooled, since the garbage collector's work is shared by all cores while the pool's per-processor caches avoid contention. The saving scales with how large the object is and how often you allocate it; for a 16-byte struct it is nothing, for a 64 KB buffer in a hot path it is large.

When not to use a pool

Small or cheap objects. The allocator is fast, and Go's escape analysis keeps many small values on the stack where no pool is needed. A pool adds Get/Put overhead and the reset step; if the object is small, you pay more than you save.

Objects with a long life or a resource attached. A pool is for scratch objects. A database connection, a file or a goroutine is not scratch: the pool may drop it at any collection without closing it, which leaks the resource. Use a purpose-built pool, such as database/sql's, for those.

Objects that must be there. Get can always return a fresh object, because the pool is emptied by the garbage collector. Code that assumes a warm pool, or counts on Put objects surviving, is wrong.

Without a profile. Pools complicate code and introduce the dirty-object class of bug. Reach for one when a profile shows allocation as a cost, and measure the result (stack and heap explains what escapes and why, and the sync.Map comparison shows the same measure-first approach for another type).

Variable-size buffers and the oversize problem

Pools of buffers have a subtle memory problem: if one request grows a buffer to 50 MB and returns it, the pool now keeps a 50 MB buffer that most requests use 4 KB of. The standard library's own fmt guards against this by refusing to return buffers larger than a limit to its pool. Do the same:

Go
const maxPooled = 64 << 10

func putBuf(buf *bytes.Buffer) {
	if buf.Cap() > maxPooled {
		return // let the garbage collector have it
	}
	pool.Put(buf)
}

Without the guard, pools can quietly hold onto peak-sized memory, which shows up as a service whose resident size never shrinks after a traffic spike.

Finding out whether allocation is your problem

Before adding a pool, confirm the diagnosis. Run the benchmark or the service with a memory profile (-memprofile in tests, /debug/pprof/allocs in a service) and look at where allocation by size and by count concentrates. If the buffers you want to pool are not near the top, the pool will not move the needle. Check also what the garbage collector is costing: with GODEBUG=gctrace=1, each collection prints the fraction of CPU it used and the heap size, and a service spending a few percent of CPU on collection has little to win from pooling. A pool pays off when allocation is both frequent and large enough that collection time is visible, and the result you want to see afterwards is fewer allocations per operation in the benchmark and a lower collection frequency in production, not just a faster microbenchmark.

Alternatives

If the objects are all the same and you control their lifecycle, a free list in a channel (make(chan *T, n)) gives a bounded pool with no garbage collector interaction and predictable memory. For per-request scratch space, allocating once per request and passing it down avoids the pool entirely. For whole-program reuse, a global buffer guarded by a mutex works at low concurrency. And for structural savings, avoiding the allocation (writing into a caller's slice, using append onto a reused slice, strings.Builder with Grow) beats pooling it.

The documentation, including the guidance about when pools are appropriate, is in the sync.Pool reference, and the Go blog's Go 1.3 release notes record its introduction.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.