1. Why you need io.Copy
If you are just starting out, it may seem that input/output is something “about files” or “about the network” (and today we are intentionally not going into either). But in reality, I/O is about moving bytes: from a string to a buffer, from a buffer to stdout, from one buffer to another, from a Reader to a Writer. And if every time you write a loop for { Read... Write... }, you will pretty quickly start to suspect that programmers are people who voluntarily rewrite the same thing over and over.
In Go, that suspicion is neatly formalized in the standard library: io.Copy does exactly what you would write by hand, just more reliably and more concisely. It is one of those functions that “seems like magic at first” and then becomes your everyday “do it properly” button. And yes—this does not replace understanding Read/Write; it is simply built on top of them.
The idea “work with streams through interfaces rather than concrete types” fits the overall Go style perfectly: small contracts and explicit error handling. The standard approach to treating error as a value is built in the same spirit.
2. io.Copy(dst, src): what it returns and why int64
io.Copy looks very simple:
written, err := io.Copy(dst, src)
At first glance it feels like: “well, it copies, so what”. But here, every word matters.
dst is an io.Writer, i.e., “where we write”.
src is an io.Reader, i.e., “where we read from”.
written is how many bytes were actually written into dst. The type is int64 because the stream can be large, and int on 32-bit systems (or in general) is not required to be sufficient for “a whole lot of bytes”.
Even more important is how to read the result:
- err == nil usually means “copied to the end of the stream”.
- err != nil means “something went wrong in the process”.
- at the same time, written can be 0 or greater than zero—and this is not a bug but a normal part of stream contracts: “partial progress”.
Let us do the most “sterile” in-memory example, with no files and no network—string → buffer:
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("hello, stream")
var dst bytes.Buffer
n, err := io.Copy(&dst, src)
fmt.Printf("n=%d err=%v dst=%q\n", n, err, dst.String())
// n=13 err=<nil> dst="hello, stream"
}
Here it is easy to see that io.Copy returned the length and no error.
Under the hood, io.Copy does what we discussed earlier: reads chunks into a buffer, writes chunks out, repeats until the end or until an error. It simply chooses the buffer itself (and does so reasonably), leaving you to focus on the program’s meaning rather than the “glue around Read”.
3. Partial progress: written > 0 together with err != nil
One of the most unpleasant beginner I/O bugs is the thought “if err != nil, then nothing worked”. For streams, that is wrong. Reality is: the stream may have managed to transfer part of the data and then failed. Or the Writer may accept part of the data and then refuse to continue (for example, “out of space”—in a general sense).
This is related to the same principle we saw in the basic Read/Write contracts: an operation may return “part of the result + an error”. With io.Copy as an example, it is clear that Copy is a regular step among operations that can fail; the important part is that an error does not invalidate what was already done before it.
To feel this hands-on, let us make a “nasty” Writer that accepts only a limited number of bytes and then starts failing.
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
type failingWriter struct {
w io.Writer
limit int
wrote int
}
func (fw *failingWriter) Write(p []byte) (int, error) {
if fw.wrote >= fw.limit {
return 0, fmt.Errorf("no space left (limit=%d)", fw.limit)
}
can := fw.limit - fw.wrote
if len(p) > can {
p = p[:can]
}
n, err := fw.w.Write(p)
fw.wrote += n
return n, err
}
func main() {
src := strings.NewReader("abcdefghijklmnop")
var dst bytes.Buffer
fw := &failingWriter{w: &dst, limit: 5}
written, err := io.Copy(fw, src)
fmt.Printf("written=%d err=%v dst=%q\n", written, err, dst.String())
// written=5 err=no space left (limit=5) dst="abcde"
}
This is partial progress: io.Copy honestly reports that it managed to write 5 bytes, and honestly reports that it could not continue.
How do you live with this in practice? Calmly, without dramatic gestures:
- written is useful diagnostics and sometimes useful business information (for example, “we managed to send N bytes”).
- err is the reason why it did not finish.
- if your operation must be “all or nothing”, then partial progress should be treated as a protocol-level problem: either roll back (if possible) or write using safe update protocols (but that is a separate big topic).
And yes, the idea “let’s just run io.Copy again” usually does not fix the situation: the data has already “partially gone out”, and repeating more often leads to duplication.
4. io.CopyN: “exactly N bytes” vs “no more than N”
With io.Copy, everything is fairly intuitive: “copy until the end”. But sometimes you need something else: “copy exactly N bytes”. For example, you are making a preview of the first 50 bytes, or reading a fixed header, or a protocol requires a strictly defined size.
For that there is io.CopyN:
written, err := io.CopyN(dst, src, n)
The key point is the word N: the function attempts to copy exactly n bytes. If there is less data in src, copying ends earlier and you get an error (often io.EOF, because the stream “ended earlier than we hoped”). At the same time, written may still be greater than zero—whatever it managed to pull out stays pulled out.
Mini example: let us take a source of 3 bytes and try to copy 10:
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("hey")
var dst bytes.Buffer
written, err := io.CopyN(&dst, src, 10)
fmt.Printf("written=%d err=%v dst=%q\n", written, err, dst.String())
// written=3 err=EOF dst="hey"
}
This behavior is very logical if you read CopyN as “a contract on volume”. There was not enough data—the contract was not fulfilled, therefore an error.
Here it is important not to confuse two similar tasks:
- “Copy no more than N bytes” is a soft limit (for example, “preview, but do not fail if the text is shorter”). This is convenient to do via io.LimitReader + io.Copy.
- “Copy exactly N bytes” is a hard requirement. For that, use io.CopyN.
This distinction helps a lot when reading other people’s code: if you see CopyN, the author most likely wanted “exactly this much”, and an error on insufficient data is an expected part of the logic.
5. Error handling: context and preserving the cause
When io.Copy returns an error, it can be quite “low-level”. Even if it is just EOF, it is often useful to understand in which operation it happened: “copying the task report”, “making a preview”, “duplicating output”.
In Go, it is idiomatic to return the error upward while adding context via fmt.Errorf("...: %w", err). This is classic practice: an error is a value, and you can enrich it with information.
Also, it is important not to turn an error into plain text if you want to preserve the “cause” inside. That is what %w (wrapping) is for, so that at the top level you can distinguish, for example, io.EOF from something else.
Let us make a small utility: move data from src to dst, and wrap errors so it is clear what exactly we were copying.
package main
import (
"fmt"
"io"
)
func copyWithContext(dst io.Writer, src io.Reader, op string) (int64, error) {
n, err := io.Copy(dst, src)
if err != nil {
return n, fmt.Errorf("%s: %w", op, err)
}
return n, nil
}
func main() {}
Notice a pleasant little detail: we return n even on error. That is honest: the function reports both “how much it managed” and “why it did not finish”.
This idea fits very well with the “errors as values” approach and the fact that in Go it is convenient to write small wrappers around standard operations to remove boilerplate and make the intent more visible.
6. Example: a task report as a stream
Now we will build a piece that looks like real development: we have data (tasks), we want to generate a text report and output it “anywhere”. Earlier we already learned to write to an io.Writer via fmt.Fprintln and to collect output in a bytes.Buffer. Now we will add neat “pouring” via io.Copy and a preview via io.CopyN.
Model and report generation into a buffer
Let us say we have a simple Task model:
package main
import (
"fmt"
"io"
)
type Task struct {
ID int
Title string
Done bool
}
func writeTasksReport(w io.Writer, tasks []Task) {
for _, t := range tasks {
fmt.Fprintf(w, "#%d [%v] %s\n", t.ID, t.Done, t.Title)
}
}
func main() {}
The writeTasksReport function knows nothing about the console, files, or any other romance. It simply writes text to a stream.
“Build → pour”: bytes.Buffer + io.Copy
Now we want: “build the report in memory, then pour it into dst in one standard way”. For this, bytes.Buffer is perfect: it is both an io.Writer and an io.Reader.
package main
import (
"bytes"
"fmt"
"io"
)
func exportReport(dst io.Writer, tasks []Task) (int64, error) {
var buf bytes.Buffer
writeTasksReport(&buf, tasks)
n, err := io.Copy(dst, &buf)
if err != nil {
return n, fmt.Errorf("export report: %w", err)
}
return n, nil
}
func main() {}
The meaning here is twofold.
First, “report generation” is separated from “where we output it”.
Second, io.Copy gives us standard behavior for moving data and the standard pair (written, err).
Report preview: io.CopyN as “the first N bytes”
Sometimes you want a “short output”: the first 40 bytes of the report, for example, to show the user a snippet (or for debugging—yes, we are human too).
package main
import (
"bytes"
"fmt"
"io"
)
func exportReportPreview(dst io.Writer, tasks []Task, max int64) (int64, error) {
var buf bytes.Buffer
writeTasksReport(&buf, tasks)
n, err := io.CopyN(dst, &buf, max)
if err != nil {
return n, fmt.Errorf("export preview: %w", err)
}
return n, nil
}
func main() {}
There is a subtlety here: if the report is shorter than max, CopyN returns an error (often EOF). That is fine if you truly wanted “exactly N”. If you wanted “no more than N”, it is better to use the soft version via io.LimitReader + io.Copy.
Mini demonstration in main
Let us put it all together in a minimal run:
package main
import (
"fmt"
"os"
)
func main() {
tasks := []Task{
{ID: 1, Title: "read about io.Copy", Done: false},
{ID: 2, Title: "don't forget to handle err", Done: false},
}
n, err := exportReport(os.Stdout, tasks)
fmt.Printf("written=%d err=%v\n", n, err)
// (the report will be printed above)
// written=... err=<nil>
}
Yes, this is a “console” example, but the point is that os.Stdout is just an io.Writer. In its place could be a buffer, a log, a network stream—anything.
A small diagram: how to think about io.Copy
Sometimes it is useful to have a picture so your brain does not try to “invent magic”. io.Copy is simply a “bridge” between interfaces:
flowchart LR
R["src: io.Reader"] -->|"Read(...)"| C["io.Copy"]
C -->|"Write(...)"| W["dst: io.Writer"]
C --> N["written (int64)"]
C --> E["err (error)"]
And that is exactly why this whole topic fits together so well: you learn to combine small building blocks—interfaces, buffers, wrappers, and a standard transfer.
7. Typical mistakes when using io.Copy / io.CopyN
Mistake #1: thinking that err != nil means “nothing was written”.
Streaming I/O does not work like that: some data may have already passed through, and io.Copy will honestly report it via written. If your code discards written and treats everything as “0 or success”, you lose important information and make debugging harder.
Mistake #2: using io.CopyN as “copy at most N, but do not complain”.
CopyN is a strict volume contract. If there is less data, there will be an error (usually EOF), and that is not an “accidental annoyance” but expected behavior. When you need the soft “no more than N”, it is better to limit the source via io.LimitReader and copy with plain io.Copy.
Mistake #3: not adding context to an error and then guessing where it broke.
I/O errors often come from a lower layer and do not explain by themselves what exactly you were trying to do. Wrap them via fmt.Errorf("...: %w", err) so the meaning of the operation is visible at the top level. This style follows directly from the general idea that error is a value that is convenient to enrich with context.
Mistake #4: losing the “cause” of an error by turning it into a string.
If you use %v instead of %w, you will keep only the text and lose the ability to distinguish causes programmatically. For our purposes, it is enough to build the habit: if you are “bubbling up” an error, you usually should preserve the original via %w.
Mistake #5: expecting that io.Copy “will close everything by itself”.
io.Copy only moves bytes. It does not “own” resources, so it cannot correctly decide when to close them. Whoever opened it closes it: this is the general principle of careful resource management in Go, often expressed via defer.