1. Why you need io.Copy
When you’re just starting to write programs, you really want to do everything “by hand”: read a chunk, write a chunk, repeat. It feels honest, like driving a manual transmission: it seems like you control every byte. But I/O has a nasty detail: a “simple loop” almost always grows into extra checks—handling partial reads, short writes, and a correct finish on EOF. And suddenly your “simple” code becomes a place where it’s easy to hide a bug that shows up once per thousand runs—which is perfect for annoying you in production.
Let’s look at the most typical manual implementation of copying from io.Reader to io.Writer. It doesn’t look scary—until you start adding details:
package main
import (
"io"
)
func CopyManual(dst io.Writer, src io.Reader) (int64, error) {
buf := make([]byte, 32*1024)
var total int64
for {
n, err := src.Read(buf)
if n > 0 {
w, werr := dst.Write(buf[:n])
total += int64(w)
if werr != nil {
return total, werr
}
if w != n {
return total, io.ErrShortWrite
}
}
if err == io.EOF {
return total, nil
}
if err != nil {
return total, err
}
}
}
Notice how much “protocol” there is: a buffer, buf[:n], checking io.EOF, checking w != n, and a separate write error. And this is still a good version. In practice, beginners often forget at least one of these things.
And that’s where io.Copy comes in: it’s a standard, well-tested implementation for moving bytes from src to dst. It does roughly the same thing, but does it for you—and (importantly!) does it in the way the entire Go ecosystem expects.
Signature and argument order
When you first see io.Copy, there are two classic emotions: “Oh, nice!” and “Wait… which argument goes first, and which one is the source?”. That’s normal: everyone carries the trauma of append(dst, src...), where the order matters too.
The signature is:
func Copy(dst io.Writer, src io.Reader) (written int64, err error)
That is, first where you write to, then where you read from. The mnemonic is simple: Copy(dst <- src). Unlike your manual implementation, the buffer is usually managed inside io.Copy.
A mini-example with “safe” types (no files), so we don’t get distracted by Close():
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("hello, copy")
var dst bytes.Buffer
written, err := io.Copy(&dst, src)
fmt.Println("written:", written) // written: 11
fmt.Println("err:", err) // err: <nil>
fmt.Println("dst:", dst.String()) // dst: hello, copy
}
There are three important observations here.
- First, written is the number of bytes written, type int64. It’s not “the number of iterations” and not “the buffer length”, but the actual amount of data transferred.
- Second, err is an error that could come either from reading or from writing. io.Copy works “between two worlds” and honestly reports if one of them starts misbehaving.
- Third, io.Copy works not only with files. Anything that implements io.Reader/io.Writer is suitable. This is exactly what makes Go code so “buildable from blocks”.
EOF, partial reads, and short writes
Inside io.Copy you have the same “protocol mechanics” you’ve already learned: read a chunk, write a chunk, repeat until the end. And here’s the best part: you don’t have to remember every time in what order to handle n and err, and what to do with io.EOF. io.Copy knows how to read the stream to completion and finish with no error when the data ends.
If you map that to a diagram, you get something like this:
flowchart LR
S["src: io.Reader"] -->|"Read(p)"| C["io.Copy (Read/Write loop)"]
C -->|"Write(p[:n])"| D["dst: io.Writer"]
C -->|"returns (written, err)"| R["caller"]
So io.Copy is the “correct loop”, packaged into the standard library.
One more useful point: io.Copy has optimizations, but you don’t need to think about them as a beginner. In some cases, copying can happen more efficiently than “read into buffer → write from buffer”, but from the outside the contract stays the same. Your code stays simple, and the standard library tries to make it fast.
Copying files without resource leaks
Now let’s get back to working with files. In previous days you already opened files via os.Open/os.Create and closed them via defer Close(). This matters because when copying files we have two resources at once: the source and the destination. And if you forget to close even one of them, you’ll get either a file descriptor leak or an incomplete write.
The classic example is a CopyFile function. It’s so canonical that it’s often shown in materials about defer: the idea is that defer helps you not forget to close files even on an early return.
package main
import (
"io"
"os"
)
func CopyFile(dstName, srcName string) (int64, error) {
src, err := os.Open(srcName)
if err != nil {
return 0, err
}
defer src.Close()
dst, err := os.Create(dstName)
if err != nil {
return 0, err
}
defer dst.Close()
return io.Copy(dst, src)
}
Why is this better than “close manually at the end”? Because if os.Create fails with an error while src is already open, you still have to close src. defer lets you “attach the close” right after a successful open, and that’s considered one of the basic practices.
Error context and partial progress
If io.Copy returns an error, beginners often go: “Well, it returned an error—what else do you want?”. But usually you want a bit more: add context. An error like permission denied without context is like a message saying “everything is bad” without specifying where.
In Go it’s common to add context via fmt.Errorf. For this lecture, a simple version is enough without going deeper: you just add the operation text.
package main
import (
"fmt"
"io"
"os"
)
func BackupTasks(dstName, srcName string) (int64, error) {
src, err := os.Open(srcName)
if err != nil {
return 0, fmt.Errorf("open source: %v", err)
}
defer src.Close()
dst, err := os.Create(dstName)
if err != nil {
return 0, fmt.Errorf("create dest: %v", err)
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return n, fmt.Errorf("copy data: %v", err)
}
return n, nil
}
There’s an important detail here: I return n even when copying fails. That’s honest: sometimes it’s useful to know what managed to get copied before something went wrong. io.Copy gives you written precisely so you can decide at the application level whether this is a “partial success” or a “complete failure”.
bufio.Writer: don’t forget Flush()
One of the most unpleasant “half-bugs” looks like this: “I copied everything, there are no errors… but the file is empty or not all of it is there”. If you write directly to a file (*os.File), then io.Copy writes straight into it. But if you wrap the file in bufio.Writer, then the write first goes into an internal buffer and only later goes “down”. In that case, finalization is Flush().
This isn’t a random quirk: bufio.Writer essentially implements the idea “accumulate small writes, and check the error at the end”. That’s why Flush() becomes an important part of the protocol, and the error can show up exactly on Flush().
Mini-example: copy a string source into buffered output.
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("buffered copy!\n")
var raw bytes.Buffer
bw := bufio.NewWriter(&raw)
_, err := io.Copy(bw, src)
if err != nil {
fmt.Println("copy:", err)
return
}
if err := bw.Flush(); err != nil {
fmt.Println("flush:", err)
return
}
fmt.Print(raw.String()) // buffered copy!
}
If you remove Flush(), you’ll get the situation “it looks like we wrote the data, but… it’s as if we didn’t”. And that’s logical: you wrote it into the bufio.Writer buffer, not into the final raw.
2. io.CopyN: copy exactly N bytes
io.Copy copies “until the end of the stream”. That’s the ideal option when you need to move an entire file, all input, a whole response, and so on. But sometimes you need something else: “copy only the first N bytes”. For example, you want to make a file preview, read a header, limit the amount of data being copied, or just implement a tiny “head”.
For that you have:
func CopyN(dst io.Writer, src io.Reader, n int64) (written int64, err error)
The behavior here is important to understand “like an engineer”. io.CopyN tries to copy exactly n bytes. If there’s less data than n, you’ll get an error (often io.EOF), and written will show how much it actually managed to copy.
Mini-example: copy the first 4 bytes.
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("abcdef")
var dst bytes.Buffer
n, err := io.CopyN(&dst, src, 4)
fmt.Println("written:", n) // written: 4
fmt.Println("err:", err) // err: <nil>
fmt.Println("dst:", dst.String()) // dst: abcd
}
Now an example where the source is shorter than what we ask for:
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("hi")
var dst bytes.Buffer
n, err := io.CopyN(&dst, src, 5)
fmt.Println("written:", n) // written: 2
fmt.Println("err:", err) // err: EOF
fmt.Println("dst:", dst.String()) // dst: hi
}
And here’s the key idea: EOF in this scenario is not “oh no, scary”, but a correct signal that there simply wasn’t enough data for the requested amount. For the application this can be a normal outcome (for example, “the file is smaller than 5 bytes, ok, preview the whole thing”), or it can be an error (for example, “we expected a fixed-format header, but it’s not there”). Your code makes that decision.
3. Where did the error come from: read or write?
One subtle point: io.Copy returns a single error, but its source can differ. Sometimes it’s “disk failed/no permissions” on write; sometimes it’s “the source broke” on read. At a beginner level you don’t need to build a complicated classification, but it’s very useful to add operation context: at least “copy tasks.txt → tasks.bak”.
Let’s make two toy types: one fails on read, the other fails on write. This isn’t so you write code like this in production, but so you can feel the mechanics.
package main
import (
"errors"
"io"
)
type failReader struct {
left int
}
func (r *failReader) Read(p []byte) (int, error) {
if r.left <= 0 {
return 0, errors.New("read failed")
}
p[0] = 'X'
r.left--
return 1, nil
}
type blackHoleWriter struct{}
func (blackHoleWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func CopyWithReadFailure() (int64, error) {
src := &failReader{left: 3}
dst := blackHoleWriter{}
return io.Copy(dst, src)
}
If you call CopyWithReadFailure, the copy will stop on the read error, but written will show that some data managed to get through. And that once again highlights why the returned byte count is not “just for show”.
4. Mini integration: backup and preview
So this doesn’t remain abstract magic, let’s add two small functions to our utility’s “file” layer. We’re not building a full CLI with commands and flags right now (that’s a separate big topic), so we’ll just show what the code for functions would look like, which you can later call from main.
The first function is a backup copy of the tasks file:
package main
import (
"fmt"
"io"
"os"
)
func BackupFile(dstPath, srcPath string) (int64, error) {
src, err := os.Open(srcPath)
if err != nil {
return 0, fmt.Errorf("open %s: %v", srcPath, err)
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return 0, fmt.Errorf("create %s: %v", dstPath, err)
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return n, fmt.Errorf("copy %s -> %s: %v", srcPath, dstPath, err)
}
return n, nil
}
The second function is a preview of the first N bytes, for example to quickly check that the file is not empty and roughly “what it’s about”, without reading the whole thing:
package main
import (
"fmt"
"io"
"os"
)
func PrintPreview(path string, limit int64) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %v", path, err)
}
defer f.Close()
if _, err := io.CopyN(os.Stdout, f, limit); err != nil && err != io.EOF {
return fmt.Errorf("preview %s: %v", path, err)
}
fmt.Println() // newline after the preview
return nil
}
Pay attention to the condition err != nil && err != io.EOF. In this specific UX we treat EOF as acceptable: if the file is shorter than limit, we still showed everything that exists. But if you were reading “strictly 16 bytes of a format header”, then EOF would be a reason to say: “The file is too short; this is not our format.”
5. Common mistakes when working with io.Copy / io.CopyN
Mistake #1: swapping dst and src.
This happens more often than you’d like to admit, especially when the variable names are something like a and b. In io.Copy(dst, src) the first is the destination, the second is the source. If you swap them, the compiler won’t save you: both interfaces can be implemented, and you’ll get weird behavior or an error “in the wrong direction”.
Mistake #2: ignoring written and assuming that “if there’s an error, nothing was copied”.
In practice, copying can partially complete. Sometimes that matters for logs and diagnostics; sometimes it matters for deciding whether to delete a “broken” destination file. So you should at least conceptually take written into account: it’s not API decoration.
Mistake #3: copying into bufio.Writer and forgetting Flush().
The most unpleasant scenario: io.Copy returned nil, you happily closed the file—and some of the data remained in the bufio.Writer buffer, leaving the file empty on disk. The discipline is simple: if there’s a bufio.Writer on top of a writer, then finalization is Flush(), and that’s exactly where an error can surface.
Mistake #4: treating io.EOF as a universal “fatal error”.
For io.Copy, end of stream usually means a correct completion with no error. For io.CopyN, EOF means “there was less data than N”, and that can be either normal (preview) or an error (you expected a strict size). If you don’t distinguish these cases, the program will either panic too much, or accept broken data too quietly.
Mistake #5: not adding context to the error and then suffering during debugging.
An error like permission denied by itself doesn’t say where exactly it happened: when opening the source, creating the destination, or during the write process. A simple addition of operation text (for example, copy A -> B) saves time and nerves, especially when the program works with multiple files.
GO TO FULL VERSION