Go sync.Pool and buffer reuse — after Put, Bytes() isn't yours
Contents8 sections
In the previous post, total allocations stayed at 256MiB however I set GOGC and GOMEMLIMIT. Reusing short-lived buffers with sync.Pool is one way to shrink it. The benchmark dropped to zero allocations, but when I kept reading Bytes() from a buffer I had already put back, user=alice turned into user=bobce. Put is the moment your right to that buffer ends, and any slice you took from it ends there too.
sync.Pool doesn’t promise to keep anything
Let’s start with what a pool doesn’t promise. The first paragraph of the docs says it plainly.
Any item stored in the Pool may be removed automatically at any time without notification.
Get doesn’t promise to hand back what you put in, either. If the pool is empty, New makes a fresh value. So the program has to work correctly even when the pool holds nothing at all.
Since Go 1.13, a GC doesn’t empty the pool in one go. It moves what was in the pool to a victim cache for one more cycle and drops it at the next GC1. The pool still gets emptied eventually.
🧪 Try it
Two GCs and the same buffer is gone
I wanted to see it disappear at the second GC. We put the same buffer back, run zero, one, and two GCs, and take it out again.
package main
import ( "bytes" "fmt" "runtime" "sync")
func main() { runtime.GOMAXPROCS(1) // with a single P, every run gives the same result made := 0 pool := sync.Pool{New: func() any { made++; return new(bytes.Buffer) }}
b := pool.Get().(*bytes.Buffer) for gcs := 0; gcs <= 2; gcs++ { pool.Put(b) for i := 0; i < gcs; i++ { runtime.GC() } got := pool.Get().(*bytes.Buffer) fmt.Printf("GCs=%d same buffer=%t New=%d\n", gcs, got == b, made) b = got }}$ go run .GCs=0 same buffer=true New=1GCs=1 same buffer=true New=1GCs=2 same buffer=false New=2On the third line, New runs a second time. The buffer survived one GC and was dropped at the second. Five runs gave the same output, and so did Go 1.27.0.
runtime.GOMAXPROCS(1) is there to make the result repeatable. A pool keeps a private slot per P, and it can’t take from another P’s private slot. With several Ps, the goroutine can move to a different P, miss the buffer it just put back, and call New.
So where does a pool fit? Several structures have similar names; which one fits depends on what you need.
| You need | Use | Why not a pool |
|---|---|---|
| reusable temp buffers | sync.Pool |
(it fits) |
| a cache with expiry | a real cache | items vanish unannounced |
| a cap on connections | a connection pool | no chance to Close |
| a cap on running jobs | a worker pool, semaphore | it doesn’t count loans |
This post puts bytes.Buffer in the pool: the GC cleans it up if it’s dropped, and it’s cheap to make again.
Writing the same record three ways
The output is data=, the input bytes, and a newline. All three functions produce the same output and errors; only the way they get a buffer differs.
🧪 Try it
Writing 1KiB through the pool allocated nothing
package reuse
import ( "bytes" "io" "sync")
const maxRetainedCapacity = 64 << 10
var buffers = sync.Pool{ New: func() any { return new(bytes.Buffer) },}
func encode(b *bytes.Buffer, payload []byte) { b.WriteString("data=") b.Write(payload) b.WriteByte('\n')}
// Baseline: a fresh buffer on every call.func WriteFresh(w io.Writer, payload []byte) error { var b bytes.Buffer encode(&b, payload) _, err := b.WriteTo(w) return err}
// Reserve the needed capacity up front. No pool.func WriteGrown(w io.Writer, payload []byte) error { var b bytes.Buffer b.Grow(len(payload) + 6) encode(&b, payload) _, err := b.WriteTo(w) return err}
// Borrow a buffer from the pool and put it back.func WritePooled(w io.Writer, payload []byte) error { b := buffers.Get().(*bytes.Buffer) b.Reset() defer func() { if b.Cap() <= maxRetainedCapacity { b.Reset() buffers.Put(b) } }()
encode(b, payload) _, err := b.WriteTo(w) return err}A buffer taken from the pool belongs to one call. After WriteTo sends the result, the deferred function clears it and puts it back. The deferred function runs even when the write fails, so no old contents leak into the next use.
$ go test -run '^$' -bench . -benchmem -benchtime=200ms -count=3 reuse.go reuse_test.goBenchmarkFresh-2 576073 434.8 ns/op 1216 B/op 2 allocs/opBenchmarkFresh-2 584186 378.3 ns/op 1216 B/op 2 allocs/opBenchmarkFresh-2 533616 436.9 ns/op 1216 B/op 2 allocs/opBenchmarkGrown-2 569814 387.4 ns/op 1152 B/op 1 allocs/opBenchmarkGrown-2 570428 387.8 ns/op 1152 B/op 1 allocs/opBenchmarkGrown-2 606288 386.5 ns/op 1152 B/op 1 allocs/opBenchmarkPooled-2 6251518 35.30 ns/op 0 B/op 0 allocs/opBenchmarkPooled-2 7291812 34.32 ns/op 0 B/op 0 allocs/opBenchmarkPooled-2 7145097 34.63 ns/op 0 B/op 0 allocs/opThese numbers are from Go 1.24.7 on linux/amd64 with 2 cores. The experiment sends 1KiB to io.Discard, so it says little about network throughput.
The fresh buffer allocates twice. When the first write is small, bytes.Buffer grabs only 64 bytes. Writing data= takes those 64 bytes, and appending the 1KiB body takes another 1152, which is where 64 + 1152 = 1216 B/op comes from. The pooled version reuses a buffer that has already grown, so it shows 0. That doesn’t mean the first New allocates nothing.
I ran the same command three times with Go 1.24.7 on this Mac (Apple M5, 10 cores, darwin/arm64). B/op and allocs/op matched exactly, and every ns/op came out lower because of the hardware.
I don’t take these numbers as “sync.Pool makes it N times faster.” io.Discard throws the bytes away, so all that’s left to measure is encoding and the temporary buffer. Once a real file or network sits behind the writer, this saving becomes a much smaller share of the request.
What do you see if you read Bytes() after Put?
You see whatever the next request wrote into the same array. b.Bytes() returns a slice that points at the buffer’s internal array, not a copy. Once the buffer is back in the pool, another request takes it, calls Reset, and writes again, and reading through the old slice shows that new content mixed in.
The bytes.Buffer docs already say the slice from Bytes() is valid only until the next modification of the buffer. With a pool, that next modification comes from another request instead of your own code.
Once you call Put, you stop calling the buffer’s methods and you stop reading through any slice you took from it. It sounds obvious, but it’s easy to miss in code.
🧪 Try it
Reading after Put printed user=bobce
encodeBad returns the slice and then puts the buffer back. encodeGood makes a copy before putting it back.
package main
import ( "bytes" "fmt" "sync")
var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
// Bad: returns a slice into the buffer, then puts the buffer back.func encodeBad(name string) []byte { b := pool.Get().(*bytes.Buffer) b.Reset() b.WriteString("user=") b.WriteString(name) out := b.Bytes() // a view of the internal array, not a copy pool.Put(b) // our right to use it ends here return out}
// Good: makes an independent copy before putting it back.func encodeGood(name string) []byte { b := pool.Get().(*bytes.Buffer) b.Reset() b.WriteString("user=") b.WriteString(name) out := append([]byte(nil), b.Bytes()...) pool.Put(b) return out}
func main() { bad := encodeBad("alice") fmt.Printf("right after Put : %s\n", bad) _ = encodeBad("bob") fmt.Printf("after next call : %s\n", bad)
good := encodeGood("alice") _ = encodeGood("bob") fmt.Printf("copy : %s\n", good)
// Reset keeps the capacity, which is why we check Cap, not Len var buf bytes.Buffer buf.Write(make([]byte, 128<<10)) fmt.Printf("\nafter big write : Len=%d Cap=%d\n", buf.Len(), buf.Cap()) buf.Reset() fmt.Printf("after Reset : Len=%d Cap=%d\n", buf.Len(), buf.Cap())}$ go run main.goright after Put : user=aliceafter next call : user=bobcecopy : user=alice
after big write : Len=131072 Cap=131072after Reset : Len=0 Cap=131072Look at the second line. bob overwrote the first three letters of alice, and the last two letters, ce, stayed where they were.
If the whole value had changed, I would have noticed right away. When only half of it changes, it can sit in the logs for a long time before anyone sees it. I ran it five times and got user=bobce all five times.
That doesn’t mean the next Get is guaranteed to return the buffer you just put back. The Get docs say not to assume any relation between what you pass to Put and what Get returns. Depending on timing, a different buffer can come out and the result can look fine. A bug that only fires sometimes is harder to fix than one that fires every time.
If the bytes need to outlive the function, there are two options. Make a copy before putting the buffer back, or change the ownership rule so the caller puts it back when done. If you choose the copy, measure again to see whether the pool still pays off once the copy is included.
One more point. Being able to call Get and Put from many goroutines at once is a property of the pool itself. It doesn’t make it safe for several goroutines to modify the buffer you took out. The pool won’t stop you from putting the same buffer back twice or from using it after Put, either. And a Pool must not be copied after first use.
Reset only sets the length to zero
Look at the last two lines of that output. After writing 128KiB and calling Reset, Len went to 0 but Cap stayed at 131072. Reset rewinds the length and read position and keeps the internal array for the next write.
That’s where the zero allocations came from. The same property means a buffer that once grew large stays large in the pool, bigger than ordinary requests need. So the decision to put a buffer back is based on Cap, not Len. That’s why WritePooled checks b.Cap() <= maxRetainedCapacity.
The 64KiB here is the per-buffer limit I picked for this experiment. It isn’t a universal best value or a memory cap for the whole pool. A buffer over the limit isn’t put back; the function just lets go of it, and that memory doesn’t go back to the OS right away. A per-buffer limit also doesn’t fix how many buffers are on loan or how much the whole pool holds. If you need an exact memory budget, manage it with a separate structure.
With a pool, correctness breaks before performance does
So I wrote the tests first. They compare the three functions’ output for an empty input, a normal input, and an input larger than the retention limit, and they check reuse after a write error and concurrent calls with separate Writers.
🧪 Try it
The error-path and concurrency tests passed under -race
package reuse
import ( "bytes" "errors" "io" "testing")
var errWrite = errors.New("write failed")
type failingWriter struct{}
func (failingWriter) Write(p []byte) (int, error) { return 0, errWrite }
func TestOutput(t *testing.T) { for _, size := range []int{0, 32, 128 << 10} { payload := bytes.Repeat([]byte("x"), size) var fresh, grown, pooled bytes.Buffer if err := WriteFresh(&fresh, payload); err != nil { t.Fatal(err) } if err := WriteGrown(&grown, payload); err != nil { t.Fatal(err) } if err := WritePooled(&pooled, payload); err != nil { t.Fatal(err) } want := "data=" + string(payload) + "\n" if fresh.String() != want || grown.String() != want || pooled.String() != want { t.Fatalf("output differs at size %d", size) } }}
func TestErrorThenReuse(t *testing.T) { if err := WritePooled(failingWriter{}, []byte("previous")); !errors.Is(err, errWrite) { t.Fatalf("write error was not preserved: %v", err) } var out bytes.Buffer if err := WritePooled(&out, []byte("next")); err != nil { t.Fatal(err) } if out.String() != "data=next\n" { t.Fatalf("unexpected output: %q", out.String()) }}
func TestConcurrentWrites(t *testing.T) { for i := 0; i < 16; i++ { t.Run("independent output", func(t *testing.T) { t.Parallel() for j := 0; j < 100; j++ { var out bytes.Buffer if err := WritePooled(&out, []byte("hello")); err != nil { t.Fatal(err) } if out.String() != "data=hello\n" { t.Fatalf("outputs got mixed: %q", out.String()) } } }) }}
func benchLoop(b *testing.B, fn func(io.Writer, []byte) error) { payload := bytes.Repeat([]byte("x"), 1024) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { if err := fn(io.Discard, payload); err != nil { b.Fatal(err) } }}
func BenchmarkFresh(b *testing.B) { benchLoop(b, WriteFresh) }func BenchmarkGrown(b *testing.B) { benchLoop(b, WriteGrown) }func BenchmarkPooled(b *testing.B) { benchLoop(b, WritePooled) }$ go test -race reuse.go reuse_test.gook command-line-arguments 1.018sTestErrorThenReuse checks that the call after a failed write produces clean output. Because the deferred function clears the buffer before putting it back, the old value didn’t leak. The test doesn’t assume the next Get returns the same buffer; the output has to be right whichever buffer comes out.
The concurrency test gives each call its own output buffer. WritePooled doesn’t provide the synchronization you’d need if several goroutines shared one Writer.
Passing the race check means the detector found no conflict on the paths that actually ran. Reading after Put breaks a rule inside a single goroutine; it isn’t a data race, and the detector can’t catch it. On this Mac (darwin/arm64, Go 1.24.7), I built own/main.go with -race and ran it 40 times.
$ go build -race -o own-race . && for i in $(seq 40); do ./own-race | sed -n 2p; done | sort | uniq -c 7 after next call : user=alice 33 after next call : user=bobceNot a single warning. Worse, 7 of the 40 runs printed user=alice, which hides the bug. In sync/pool.go, a race build makes Put deliberately drop one item in four. When the buffer is dropped, New hands out a fresh one, so bob never overwrites alice’s array. If you only check this under -race, the bug hides about one time in four.
Grow alone cuts an allocation without a pool
This is why the benchmark included WriteGrown. If you know the output length in advance, you can reserve the capacity in one step. The only change is one line, b.Grow(len(payload) + 6), before encode.
| Version | B/op · allocs/op | Cost |
|---|---|---|
| fresh buffer | 1216 · 2 | none (baseline) |
Grow first |
1152 · 1 | needs the length |
| pooled | 0 · 0 | return rules, Cap check, copying |
In the original run, allocations went from 2 to 1 while ns/op ranges overlapped: 378.3–436.9 versus 386.5–387.8. On this Mac it went from 128.5ns to 119.1ns, about 7% less. Halving the allocations didn’t halve the time. Here the pool won by a wide margin, but that result is tied to a fixed 1KiB input written to io.Discard.
If input sizes vary or there are many concurrent requests, this result isn’t enough. Mix large requests in among small ones, and look at allocations and retained memory together over a long run with many GCs. Since the pool can be emptied at any time, also check that the code is correct and affordable when every call goes through New.
Decide the pool’s scope, too. A new pool per request leaves nothing to reuse between calls. Mixing every type and size in one pool blurs what’s inside and what state it comes out in. Start where the example does: one kind of temporary object with a clear rule for putting it back.
Wrap-up
sync.Poolis a box for reusing temporary objects you could rebuild. It promises neither storage nor a count, and a buffer left in it was gone after two GCs.- Calling
Putends your right to the buffer. Slices you took fromBytes()end with it, so copy anything that has to leave before you put the buffer back. ResetzeroesLenand leavesCapalone. Both the zero allocations and the oversized leftovers come from that, so decide onCapwhether to put a buffer back.- Reading after
Putisn’t a data race, so-racedoesn’t catch it. In a race build it was hidden in roughly one run out of four. - When the output length is known,
Growalone took allocations from 2 to 1. The time didn’t drop by the same proportion.
What I kept coming back to while writing this wasn’t a benchmark number but the single line user=bobce. The pool took allocations to zero, but buffers now live longer, and the code has a new job: putting each one back at the right moment.
References
- sync.Pool: items may be removed without notification, no copying after first use, no assumed relation between
PutandGet - bytes.Buffer.Bytes: the slice is valid only until the next buffer modification
- bytes.Buffer.Reset: keeps the underlying storage
- io.Writer: implementations must not retain the slice
- Go 1.13 Release Notes · sync: the pool keeps some objects across GCs
- sync/pool.go (go1.24.7):
poolCleanupmoving items to the victim cache, and the race-build code that drops onePutin four - Data Race Detector · How To Use: it only finds races that happen at runtime
Footnotes
-
The Go 1.13 release notes only say the pool no longer has to be fully repopulated after every GC and now retains some objects. The one-cycle victim cache is in
poolCleanupinsync/pool.go. ↩