skip to content

When does io.CopyBuffer beat io.Copy in Go, and what makes io.CopyBuffer panic?

level: middleimportance: nice to knowfreq 28%

answer

  1. who owns the scratch space
  2. one allocation per call adds up
  3. you can hand the copy your own buffer
  4. a non-nil empty buffer is fatal

basics

~10 s

io.CopyBuffer takes the scratch buffer as a parameter, so code copying many streams can reuse one instead of letting every io.Copy call allocate a fresh 32 KB. A non-nil buffer of length zero panics.

solid answer

~50 s

`io.Copy` allocates its own scratch buffer — around 32 KB — on every call. A process running thousands of concurrent copies therefore holds thousands of those buffers, and a loop doing many short copies allocates one per iteration. `io.CopyBuffer(dst, src, buf)` performs the identical copy but uses the buffer you supply, so a worker can allocate once and reuse it, or draw one from a `sync.Pool`. Passing `nil` for `buf` makes it behave exactly like `io.Copy`; passing a non-nil buffer of length zero **panics**, because a zero-length buffer would make the copy loop forever with no progress. One caveat keeps this from being a free win: when the destination or the source implements a faster bulk-transfer method, both `io.Copy` and `io.CopyBuffer` take that path and your buffer is never touched. Measure with a benchmark and `-benchmem` before adding the plumbing.

code

go · 8 lines
go
buf := make([]byte, 64*1024) // length, not just capacity
for _, src := range sources {
	if _, err := io.CopyBuffer(dst, src, buf); err != nil {
		return err
	}
}
// io.CopyBuffer panics if buf is non-nil and len(buf) == 0,
// e.g. make([]byte, 0, 64*1024)

go deeper

for a junior

Remember that io.CopyBuffer is io.Copy with the scratch buffer passed in, that nil means let it allocate one, and that a non-nil empty slice panics. Know that make([]byte, 0, n) is length zero, not length n.

for a middle

Explain where the allocation comes from: io.Copy allocates roughly 32 KB per call, so many concurrent or many repeated copies add up. Be able to say why a zero-length buffer is a panic rather than a returned error.

for a senior

Treat it as an optimisation you justify with numbers: benchmark with -benchmem, know that a bulk-transfer fast path can make the buffer unused, and get the reuse discipline right so concurrent copies never share one buffer.

for a principal

Decide when this complexity is worth carrying at all: whether the allocation shows in a real profile, whether a pooled buffer belongs in a shared helper, and what the memory ceiling is once per-copy buffer size is multiplied by the concurrency you allow.

## Where the allocation comes from `io.Copy(dst, src)` needs somewhere to put the bytes between the `Read` and the `Write`. It allocates that scratch space itself — a slice of roughly 32 KB — each time it is called. For a program that copies one file, this is invisible. For two other shapes it is not: - **Many concurrent copies.** A relay holding 5,000 in-flight streams holds 5,000 live scratch buffers. At ~32 KB each that is around 160 MB of live heap that exists only as plumbing, and it is heap the collector must scan and keep. - **Many short copies in a loop.** Copying ten thousand small objects in sequence allocates ten thousand 32 KB buffers, each used briefly and discarded. The bytes moved may be trivial while the allocation volume is not, and that shows up as GC pressure rather than as time inside the copy. ## What `io.CopyBuffer` changes `func CopyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error)` It is the same copy loop, with the scratch space passed in. The rules for `buf` are precise and worth memorising: - `buf == nil` — one is allocated internally, exactly as `io.Copy` does. `io.Copy` is in fact defined in terms of this behaviour. - `buf` non-nil with `len(buf) > 0` — that slice is used for the whole transfer. - `buf` non-nil with `len(buf) == 0` — **panic**. A zero-length buffer would make every `Read` return zero bytes and the loop would spin without progress, so the library refuses rather than hanging. The zero-length panic catches a real bug: `make([]byte, 0, 64*1024)` looks like a 64 KB buffer and is a zero-length one. The idiom is `make([]byte, 64*1024)` — length, not just capacity. ## The caveat that eats the optimisation Both `io.Copy` and `io.CopyBuffer` first check whether the source or the destination can transfer bytes in bulk by itself, and if so they delegate and never allocate or touch a scratch buffer at all. Several common types do exactly that, so for some reader/writer pairs the buffer argument is simply unused and `io.CopyBuffer` buys nothing. This is why the honest answer to "should we switch to CopyBuffer?" is "benchmark it": run the copy under `go test -bench` with `-benchmem` and look at `B/op` and `allocs/op`. If the allocation was never happening, the change adds complexity for nothing. ## Reuse discipline A reused buffer is mutable scratch space overwritten by every `Read`, so it must not be shared between concurrent copies. Two safe patterns: - one buffer per goroutine, allocated when the worker starts and reused for the life of the worker; - a `sync.Pool` of buffers, taken at the start of a copy and returned at the end, which suits a request-per-goroutine service where the concurrency level is not fixed. What is not safe is a package-level buffer shared by whatever calls the helper — that is a data race, and it is the kind that the race detector will report as soon as two copies overlap. ## Choosing a size 32 KB is a reasonable default and there is nothing magic about beating it. Larger buffers reduce the number of `Read`/`Write` calls, which helps when each of those is a syscall and the underlying medium is fast; they also raise per-copy memory, which is the very thing you were trying to reduce. If you change the size, change it because a benchmark on your workload moved, not because a larger number looks faster. ## What to say in an interview "`io.Copy` allocates ~32 KB per call; `io.CopyBuffer` lets me supply and reuse that buffer. `nil` means allocate for me, and a non-nil empty buffer panics. It only helps when no bulk-transfer fast path applies, so I'd confirm with `-benchmem` first."

  • What size should the buffer be, and how would you decide?
    Start at the ~32 KB default. A larger buffer only helps when each read and write is an expensive call and the medium can fill it, and it costs live memory times your concurrency. Decide with `go test -bench` and `-benchmem` on the real reader/writer pair, watching `allocs/op` and `B/op`, not by picking a bigger round number.
  • Can several goroutines share one buffer passed to io.CopyBuffer?
    No. The buffer is scratch space overwritten by every read, so two concurrent copies sharing it corrupt each other's data and it is a genuine data race the `-race` build will flag. Give each goroutine its own buffer, or take one from a `sync.Pool` for the duration of the copy and return it afterwards.
  • What does passing nil as the buffer to io.CopyBuffer do?
    It behaves exactly like `io.Copy`: the function allocates a scratch buffer of its own for that call. Only a non-nil slice of length zero is an error, and it is signalled with a panic rather than a returned error because the copy could not make progress.

saying these in an interview costs you the question

  • Passes make([]byte, 0, 64*1024) and gets a panic
  • Shares one CopyBuffer buffer across concurrent copies
  • Claims io.Copy never allocates anything
  • Switches to CopyBuffer without ever benchmarking allocations
  • Assumes a bigger buffer is always faster