How Go's io.Copy Avoids Copying: sendfile, splice and the Copy Protocol
io.Copy does not copy. It asks the source and the destination whether they can do the transfer themselves, and net.TCPConn answers with sendfile and splice, moving bytes inside the kernel with no user-space buffer at all.
TL;DR:
io.Copy(dst, src)does not have a loop in it that reads into a buffer and writes it out — not in the common case. It first asks whethersrcimplementsio.WriterToordstimplementsio.ReaderFrom, and if either does, it delegates the whole operation.*net.TCPConnimplements both, using Linuxsendfileandspliceto move bytes between a file descriptor and a socket entirely inside the kernel. No user-space buffer is allocated, and nomemcpyhappens at all.
There is a famous Go performance folklore about io.Copy being slower than a
manual read/write loop, because io.Copy allocates a 32 KB buffer. That
folklore was true once and is now mostly wrong — and understanding why is a
good tour of Go’s “the fast path is a method call” philosophy.
The copy protocol
// src/io/io.go:405
// copyBuffer is the actual implementation of Copy and CopyBuffer.
// if buf is nil, one is allocated.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
// If the reader has a WriteTo method, use it to do the copy.
// Avoids an allocation and a copy.
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rf, ok := dst.(ReaderFrom); ok {
return rf.ReadFrom(src)
}Two interface assertions, and a comment that says exactly what the optimisation buys: “Avoids an allocation and a copy.”
This is the whole trick. io.Copy is a protocol, not an implementation. The
standard library defines two optional interfaces:
// src/io/io.go:193
// WriterTo is the interface that wraps the WriteTo method.
//
// WriteTo writes data to w until there's no more data to write or
// when an error occurs. The return value n is the number of bytes
// written. Any error encountered during the write is also returned.
//
// The Copy function uses WriterTo if available.
type WriterTo interface {
WriteTo(w Writer) (n int64, err error)
}// src/io/io.go:182
// ReaderFrom is the interface that wraps the ReadFrom method.
//
// ReadFrom reads data from r until EOF or error.
// The return value n is the number of bytes read.
// Any error except EOF encountered during the read is also returned.
//
// The [Copy] function uses [ReaderFrom] if available.
type ReaderFrom interface {
ReadFrom(r Reader) (n int64, err error)
}And io.Copy honours them. The order matters: WriterTo on the source wins
over ReaderFrom on the destination. That is not arbitrary — the source’s
WriteTo knows how to move its data most efficiently, and the destination’s
ReadFrom knows how to receive it most efficiently. The source’s opinion is
taken first because in practice the source is the more constrained side (a
file, a socket) and the destination is more generic.
Who implements these?
A surprising number of standard library types do:
| Type | Implements | Mechanism |
|---|---|---|
*bytes.Buffer | WriterTo, ReaderFrom | copy on the underlying slice |
*bytes.Reader | WriterTo | copy on the underlying slice |
*strings.Reader | WriterTo | copy on the string |
*os.File | WriteTo (via io.Copy on *bytes.Reader/file) | sendfile on Linux |
*net.TCPConn | WriteTo, ReadFrom | splice, sendfile |
*net.UnixConn | WriteTo, ReadFrom | splice |
*net/http.response | ReadFrom | sendfile for file bodies |
So io.Copy(dst, src) where src is an *os.File and dst is a
*net.TCPConn never touches user space at all.
The TCP path: splice first, then sendfile
// src/net/tcpsock_posix.go:47
func (c *TCPConn) readFrom(r io.Reader) (int64, error) {
if n, err, handled := spliceFrom(c.fd, r); handled {
return n, err
}
if n, err, handled := sendFile(c.fd, r); handled {
return n, err
}
return genericReadFrom(c, r)
}Three tiers, tried in order:
spliceFrom— pipe-to-pipe transfer between two sockets. Only works if the source is a TCP or Unix socket.sendFile— file-to-socket. Only works if the source is a file descriptor.genericReadFrom— the ordinary buffered copy.
The handled flag is the interesting part. It is not “did it succeed” — it
is “did it do any work”. A partial success still returns handled == true,
so the caller knows not to retry with the generic path and duplicate bytes.
Splice: moving bytes between sockets
splice is a Linux syscall that moves data between a pipe and a socket
without a user-space buffer. But splice cannot copy socket-to-socket
directly, so Go uses a pipe as a shuttle:
// src/net/splice_linux.go:14
// spliceFrom transfers data from r to c using the splice system call to minimize
// copies from and to userspace. c must be a TCP connection.
// Currently, spliceFrom is only enabled if r is a TCP or a stream-oriented Unix connection.
//
// If spliceFrom returns handled == false, it has performed no work.
func spliceFrom(c *netFD, r io.Reader) (written int64, err error, handled bool) {
var remain int64 = 1<<63 - 1 // by default, copy until EOF
lr, ok := r.(*io.LimitedReader)
if ok {
remain, r = lr.N, lr.R
if remain <= 0 {
return 0, nil, true
}
}
var s *netFD
switch v := r.(type) {
case *TCPConn:
s = v.fd
case tcpConnWithoutWriteTo:
s = v.fd
case *UnixConn:
if v.fd.net != "unix" {
return 0, nil, false
}
s = v.fd
default:
return 0, nil, false
}
written, handled, err = pollSplice(&c.pfd, &s.pfd, remain)
if lr != nil {
lr.N -= written
}
return written, wrapSyscallError("splice", err), handled
}The type switch is the guard: only sockets can be spliced. Note the
tcpConnWithoutWriteTo case — that is a trick to avoid infinite recursion
(we will come back to it).
The actual mechanism is in internal/poll:
// src/internal/poll/splice_linux.go:33
func Splice(dst, src *FD, remain int64) (written int64, handled bool, err error) {
p, err := getPipe()
if err != nil {
return 0, false, err
}
defer putPipe(p)
var inPipe, n int
for err == nil && remain > 0 {
max := maxSpliceSize
if int64(max) > remain {
max = int(remain)
}
inPipe, err = spliceDrain(p.wfd, src, max)
// The operation is considered handled if splice returns no
// error, or an error other than EINVAL. An EINVAL means the
// kernel does not support splice for the socket type of src.
// The failed syscall does not consume any data so it is safe
// to fall back to a generic copy.
//
// spliceDrain should never return EAGAIN, so if err != nil,
// Splice cannot continue.
//
// If inPipe == 0 && err == nil, src is at EOF, and the
// transfer is complete.
handled = handled || (err != syscall.EINVAL)
if err != nil || inPipe == 0 {
break
}
p.data += inPipe
n, err = splicePump(dst, p.rfd, inPipe)
if n > 0 {
written += int64(n)
remain -= int64(n)
p.data -= n
}
}Read the handled comment carefully — it is the kind of reasoning that only
appears in code that has been debugged in production:
The operation is considered handled if splice returns no error, or an error other than EINVAL. An EINVAL means the kernel does not support splice for the socket type of src. The failed syscall does not consume any data so it is safe to fall back to a generic copy.
So EINVAL is the only error that permits a fallback, and the fallback is
safe precisely because the failed call moved nothing. Every other error is
fatal, because some data may have been consumed and a generic copy would
duplicate it.
The pipe itself is pooled — getPipe/putPipe — because creating a pipe is
a syscall and this is a hot path.
And spliceDrain has an invariant worth reading:
// src/internal/poll/splice_linux.go:76
// spliceDrain moves data from a socket to a pipe.
//
// Invariant: when entering spliceDrain, the pipe is empty. It is either in its
// initial state, or splicePump has emptied it previously.
//
// Given this, spliceDrain can reasonably assume that the pipe is ready for
// writing, so if splice returns EAGAIN, it must be because the socket is not
// ready for reading.That invariant is what lets the code skip a poll before every splice: if
the pipe is known empty, the kernel will not block on the write side, so
EAGAIN can only mean the socket was not ready.
sendfile: file to socket
For a file source, sendfile does the whole job in one syscall — the kernel
moves pages from the page cache straight into the socket, with no copy into
user space.
// src/net/sendfile.go:17
// sendFile copies the contents of r to c using the sendfile
// system call to minimize copies.
//
// if handled == true, sendFile returns the number (potentially zero) of bytes
// copied and any non-EOF error.
//
// if handled == false, sendFile performed no work.
func sendFile(c *netFD, r io.Reader) (written int64, err error, handled bool) {
if !supportsSendfile() {
return 0, nil, false
}
var remain int64 = 0 // 0 writes the entire file
lr, ok := r.(*io.LimitedReader)
if ok {
remain, r = lr.N, lr.R
if remain <= 0 {
return 0, nil, true
}
}
// r might be an *os.File or an os.fileWithoutWriteTo.
// Type assert to an interface rather than *os.File directly to handle the latter case.
f, ok := r.(syscall.Conn)
if !ok {
return 0, nil, false
}
sc, err := f.SyscallConn()
if err != nil {
return 0, nil, false
}
var werr error
err = sc.Read(func(fd uintptr) bool {
written, werr, handled = poll.SendFile(&c.pfd, fd, remain)
return true
})Note the type assertion: r.(syscall.Conn), not r.(*os.File). The comment
explains why:
r might be an
*os.Fileor anos.fileWithoutWriteTo. Type assert to an interface rather than*os.Filedirectly to handle the latter case.
os.fileWithoutWriteTo is a wrapper that hides the WriteTo method —
because os.File.WriteTo would otherwise call io.Copy, which would call
TCPConn.ReadFrom, which would call sendFile again, and around we go. The
sc.Read(func(fd uintptr) bool {...}) pattern is syscall.RawConn, which
gives the code access to the raw file descriptor while holding the runtime
lock for that descriptor — which is exactly what you need to call
sendfile safely.
The platform differences are handled in internal/poll:
// src/internal/poll/sendfile_unix.go:30
func SendFile(dstFD *FD, src uintptr, size int64) (n int64, err error, handled bool) {
if goos := runtime.GOOS; goos == "linux" || goos == "android" {
// Linux's sendfile doesn't require any setup:
// It sends from the current position of the source file and
// updates the position of the source after sending.
return sendFile(dstFD, int(src), nil, size)
}
// Non-Linux sendfile implementations don't use the current position of the source file,
// so we need to look up the position, pass it explicitly, and adjust it after
// sendfile returns.
start, err := ignoringEINTR2(func() (int64, error) {
return syscall.Seek(int(src), 0, io.SeekCurrent)
})
if err != nil {
return 0, err, false
}
pos := start
n, err, handled = sendFile(dstFD, int(src), &pos, size)
if n > 0 {
ignoringEINTR2(func() (int64, error) {
return syscall.Seek(int(src), start+n, io.SeekStart)
})
}
return n, err, handled
}Linux’s sendfile takes a nil offset and updates the file position itself.
The BSDs want an explicit offset and do not update it, so Go has to Seek
before and after. That is a syscall per copy on the BSDs — a cost the Linux
path avoids entirely.
The recursion problem, and how Go solves it
There is a genuine cycle here:
io.Copy(dst, src)callssrc.WriteTo(dst)ifsrchas one.*os.File.WriteTocallsio.Copy(dst, f).*net.TCPConn.ReadFromcallssendFile, which is fine.- But
*net.TCPConn.WriteTocallsio.Copy(w, c)— and ifwis something with aReadFrom, that calls back intoc.ReadFrom…
Go breaks the cycle with wrapper types that hide the offending method:
// src/net/net.go:778
// noWriteTo can be embedded alongside another type to
// hide the WriteTo method of that other type.
type noWriteTo struct{}
// WriteTo hides another WriteTo method.
// It should never be called.
func (noWriteTo) WriteTo(io.Writer) (int64, error) {
panic("can't happen")
}// src/net/net.go:788
// tcpConnWithoutWriteTo implements all the methods of *TCPConn other
// than WriteTo. This is used to permit WriteTo to call io.Copy
// without leading to a recursive call to WriteTo.
type tcpConnWithoutWriteTo struct {
noWriteTo
*TCPConn
}And the fallback implementations use the other wrapper:
// src/net/net.go:773
func genericReadFrom(c *TCPConn, r io.Reader) (n int64, err error) {
// Use wrapper to hide existing r.ReadFrom from io.Copy.
return io.Copy(tcpConnWithoutReadFrom{TCPConn: c}, r)
}// src/net/net.go:796
// Fallback implementation of io.WriterTo's WriteTo, when zero-copy isn't applicable.
func genericWriteTo(c *TCPConn, w io.Writer) (n int64, err error) {
// Use wrapper to hide existing w.WriteTo from io.Copy.
return io.Copy(w, tcpConnWithoutWriteTo{TCPConn: c})
}The pattern is elegant: embed a struct that implements the hidden method with
a panic("can't happen"), and the interface assertion in io.Copy fails
because the promoted method is shadowed.
The generic fallback
If none of the fast paths apply, io.Copy falls back to a plain
read/write loop with a 32 KB buffer:
// src/io/io.go:417
if buf == nil {
size := 32 * 1024
if l, ok := src.(*LimitedReader); ok && int64(size) > l.N {
if l.N < 1 {
size = 1
} else {
size = int(l.N)
}
}
buf = make([]byte, size)
}The LimitedReader special case is a small nicety: if you are copying at
most 100 bytes, you get a 100-byte buffer rather than a 32 KB one.
And io.CopyBuffer lets you supply your own:
// src/io/io.go:391
// CopyBuffer is identical to Copy except that it stages through the
// provided buffer (if one is required) rather than allocating a
// temporary one. If buf is nil, one is allocated; otherwise if it has
// zero length, CopyBuffer panics.
//
// If either src implements [WriterTo] or dst implements [ReaderFrom],
// buf will not be used to perform the copy.The last sentence is the caveat that trips people up: your buffer is
ignored if a fast path applies. If you are benchmarking with CopyBuffer
and wondering why your buffer is not being used, that is why.
net/http uses this too
The HTTP server’s response writer implements ReadFrom, which is why
serving a static file is fast:
// src/net/http/server.go
func (w *response) ReadFrom(src io.Reader) (n int64, err error) {The server checks whether the source is a file and, if so, uses sendfile to
push it straight to the socket. That is why http.FileServer can saturate a
10 Gbit link without the Go process ever touching the payload bytes.
Trade-offs
Interface assertions cost a little. Two type assertions per io.Copy
call. In a tight loop calling io.Copy on non-file, non-socket readers, that
is pure overhead — the assertions will fail and you pay for the buffer
anyway.
The fast path is not always applicable. sendfile needs a real file
descriptor. A bytes.Reader over a []byte is not one, and neither is a
gzip.Reader wrapping a file. If your data is compressed in flight, the
zero-copy path is gone.
Partial success is possible and handled. A sendfile that transfers
half the file and then hits EAGAIN returns handled == true with a partial
count. The caller must not retry from the start.
io.CopyBuffer’s buffer may be ignored. Documented, but surprising.
The handled flag is subtle. It means “did any work happen”, not “did
it fully succeed”. Reading it as “did it succeed” will produce duplicated
data.
Linux is the best-supported platform. sendfile on Linux needs no
offset bookkeeping; the BSDs need an extra Seek before and after.
Caveats
os.File.WriteTo in Go 1.22+ is deliberately limited. The
os.fileWithoutWriteTo wrapper exists because os.File’s own WriteTo
would recurse. If you see WriteTo not being used on a file, that is
intentional.
The splice path only works socket-to-socket. A file source goes down
the sendfile path, not the splice path.
splice needs a pipe, and pipes are a limited resource. Go pools them
(getPipe/putPipe), but under enough concurrency you can still hit the
per-process pipe limit.
None of this applies to io.ReadAll. io.ReadAll has no fast path; it
always grows a buffer.
http.ResponseWriter and io.Writer are different. Wrapping a
*net.TCPConn in bufio.Writer hides the ReadFrom method, because the
wrapper does not implement it. If you buffer your writes, you lose the
zero-copy path.
Wrapping up
The one thing to remember: io.Copy is a dispatch, not a loop. The
first thing it does is check whether the source or destination can do the
transfer themselves, and if so it gets out of the way. The actual copying is
somebody else’s problem, and for files and sockets that somebody is the
kernel.
The part people get wrong: the handled flag. It means “did any work
happen”, not “did it succeed”. Falling back to a generic copy after a
partial sendfile duplicates bytes. The code is careful about this
(EINVAL is the only error that permits a fallback, because it consumes no
data), and your code has to be careful too if you ever call the net
package’s internal helpers.
What to go do about it: if you are serving files over HTTP or proxying
them, check that you are not accidentally wrapping the connection in a
bufio.Writer — that hides ReadFrom and silently gives up the zero-copy
path. And if you are copying from a *os.File to a *net.TCPConn by hand,
just call io.Copy and let the protocol do its job.
Further reading
src/io/io.go—Copy,CopyBuffer,copyBuffer, and theWriterToandReaderFrominterface definitions.src/net/tcpsock_posix.go—TCPConn.readFromandwriteTo, the three-tier dispatch.src/net/sendfile.go— thesendFilehelper, including thesyscall.Conntype assertion and theRawConnusage.src/internal/poll/sendfile_unix.go— the per-platformsendfilewrapper, the Linux vs BSD offset handling, and the error-classification switch.src/net/splice_linux.go—spliceFromandspliceTo.src/internal/poll/splice_linux.go—Splice,spliceDrain,splicePump, and the pooled pipe.src/net/net.go—genericReadFrom,genericWriteTo,noWriteToandtcpConnWithoutWriteTo: the recursion-breaking wrappers.src/os/file.go—fileWithoutWriteTo, the other half of the recursion-breaking story.