1. Decorators in I/O: why they matter
When you are just starting out with programming, it can feel like every new “effect” in code requires rewriting logic: “I want to limit reading — I’ll write my own Read”, “I want to log input data — I’ll write my own Read again”, “I want to write to two places — so I’ll have to duplicate calls”. In Go there is a much calmer path: take an existing io.Reader or io.Writer and “wrap” it in another object that adds behavior without changing the core code.
In the I/O world, this kind of wrapper is often called a decorator. The idea is simple: we have a stream, and we build a mini processing pipeline out of standard “building blocks”.
Imagine water in a pipe. io.Reader is the pipe you can “scoop” data from. io.Writer is the pipe you “pour” data into. Decorators are attachments: a flow limiter, a tee that diverts some water into a separate jar, or a splitter that pours into two buckets at once. And you do all of it without welding a new pipe out of whatever pots you have lying around.
io.LimitReader: no more than N bytes
When we read data from a stream, we do not always trust the source. Sometimes the source is fine, but we want a “preview” — read only the first bytes. Sometimes the source is suspicious (user input, file, network), and we want to protect ourselves from “I’ll send you 10 gigabytes and you’ll keep reading until you grow old”. io.LimitReader is the standard way in Go to say: “read, but only up to the limit”.
Its signature looks like this:
func LimitReader(r Reader, n int64) Reader
It is important to understand the philosophy: LimitReader does not read by itself. It returns a new Reader that behaves as if the underlying stream contains exactly n bytes. Once the limit is exhausted, you will get io.EOF, even if the underlying stream still has tons of data.
Mini example: read a string, but only the first 5 bytes
Here we use memory (bytes.Buffer) so we do not get distracted by files and the network.
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
src := bytes.NewBufferString("abcdefg")
r := io.LimitReader(src, 5)
buf := make([]byte, 8)
n, err := r.Read(buf)
fmt.Printf("n=%d data=%q err=%v\n", n, buf[:n], err) // n=5 data="abcde" err=<nil>
}
The key detail is that buf has size 8, but Read returned only 5 bytes — the limit kicked in.
What to remember about LimitReader
LimitReader is like a “police officer at the entrance”. It does not prevent the data from existing; it simply does not let you read it through this specific interface. That is why during debugging you might sometimes feel like “where did the data go?” — and the answer is that you put a limiter in place yourself and forgot about it.
2. io.TeeReader: read and copy in parallel
io.TeeReader has a very “real-life” job: sometimes you want reading from a stream to automatically leave a trace — for example, in logs, in a debug buffer, or in an extra side output. At the same time, we do not want to rewrite the read loop and manually do w.Write(buf[:n]) everywhere. TeeReader is exactly for this: it returns a Reader that on every read copies the bytes read into a Writer.
Signature:
func TeeReader(r Reader, w Writer) Reader
It sounds almost magical, but there is no magic: you read from TeeReader, and internally it does two things — gets bytes from r and writes those bytes into w.
And this is where an important nuance begins: if writing into w breaks, the error will surface as a read error. From the caller’s perspective, that is logical: “I wanted to read a stream, but reading has a side effect, and that side effect failed”.
Mini example: read in chunks and build a “debug copy”
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("go-go-go")
var debugCopy bytes.Buffer
r := io.TeeReader(src, &debugCopy)
buf := make([]byte, 2)
n, _ := r.Read(buf)
fmt.Printf("chunk=%q\n", buf[:n]) // chunk="go"
fmt.Printf("debug=%q\n", debugCopy.String()) // debug="go"
}
Yes, we read only one chunk — and the “copy” contains only what we actually read. TeeReader does not “read ahead” by itself.
Where this is actually useful
In practice, TeeReader is often used as an “eavesdropper” when processing data. For example, you parse an input stream and want to keep the first N bytes so that if an error occurs you can show a clear message or record diagnostic information. This pairs well with Go’s “errors are values” idea: we do not try to hide everything; on failure we try to keep useful context. This philosophy in Go often comes up around I/O and error handling.
4. io.MultiWriter: write to multiple places at once
Sometimes the task is symmetric: not “read and copy”, but “write to multiple receivers at once”. The clearest example is writing to the console and to a buffer at the same time (for tests/saving the result/further processing). Or writing into two different buffers. Or writing to a log and to the primary output. That is what io.MultiWriter is for.
Signature:
func MultiWriter(writers ...Writer) Writer
You get a new Writer that, for each Write(p), calls Write(p) on each nested writer.
There is also important honesty here: this is not a transaction. If one writer managed to write and the second one failed, it is impossible to “roll back” the first write. So MultiWriter is more like a convenient splitter than a bank-style “all or nothing” operation.
Mini example: write into two buffers
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
var a bytes.Buffer
var b bytes.Buffer
w := io.MultiWriter(&a, &b)
_, _ = w.Write([]byte("Hi"))
fmt.Println(a.String(), b.String()) // Hi Hi
}
If at this point you thought: “Wow, you can do a ‘double write’ without copy-paste” — yes, exactly. And that is great news for your future: copy-paste is like sugar — tasty at first, then painful.
5. Pipelines: combining LimitReader, TeeReader, MultiWriter
After you have seen these three things separately, the natural question is: “can we assemble them together?” You can — and that is very much Go style: small simple building blocks that combine into clear constructions.
It is important to remember that wrapper order changes the meaning. If you limit the stream and then tee it, only the limited part will end up in the “copy”. If you tee first and then limit the resulting reader, the “copy” will still be written only when you read — so it will also be limited, but the pipeline is arranged differently.
A small mental diagram of how this looks:
flowchart LR
A["Data source (io.Reader)"] --> B["io.LimitReader (limit)"]
B --> C["io.TeeReader (copy to Writer)"]
C --> D["Your code reads in chunks"]
C --> E["Debug copy (io.Writer)"]
In real code this is usually a one-liner, but it is important to keep the “pipeline” model in mind.
Cheat sheet: who does what and when to choose it
So you do not have to keep everything in your head as a mess of terms, it helps to have a small map:
| Tool | Takes | Returns | Idea | Typical use case |
|---|---|---|---|---|
|
|
|
Limit reading to n bytes | preview, protection from overly large input |
|
|
|
Read and copy what was read in parallel | diagnostics, input logging, “save what we processed” |
|
|
|
Write to multiple destinations at the same time | stdout + buffer, stdout + log, 2 buffers |
There is no “better/worse” here — these are different pipe attachments.
6. Decorators in a learning app
Now we have a good opportunity to take a step toward “an application that grows”. Let’s say we have a simple task model (mini todo). Before this, we could print tasks using fmt.Println directly to the console. Today we will make it more grown-up: we will learn how to format a report and send it to any io.Writer, and then we will plug in decorators.
Task model and a function that writes a single line
package main
import (
"fmt"
"io"
)
type Task struct {
ID int
Title string
Done bool
}
func writeTaskLine(w io.Writer, t Task) error {
_, err := fmt.Fprintf(w, "%d\t%v\t%s\n", t.ID, t.Done, t.Title)
return err
}
Notice the idea: writeTaskLine knows nothing about os.Stdout. It can simply write to any io.Writer.
Writing a list of tasks to a stream
package main
import "io"
func writeTasks(w io.Writer, tasks []Task) error {
for _, t := range tasks {
if err := writeTaskLine(w, t); err != nil {
return err
}
}
return nil
}
There is no magic here, but it is the foundation: once your code accepts an io.Writer, you can plug in anything — memory, console, “double output”, logging, and so on.
io.MultiWriter: console and buffer at the same time
Now let’s make it so the report is printed to the user and also saved in memory (for example, to later show a preview or run a test).
package main
import (
"bytes"
"io"
"os"
)
func main() {
tasks := []Task{
{ID: 1, Title: "read about io.Reader", Done: true},
{ID: 2, Title: "don't forget to check err", Done: false},
}
var copyBuf bytes.Buffer
w := io.MultiWriter(os.Stdout, ©Buf)
_ = writeTasks(w, tasks)
}
Yes, here we “swallowed” the error using _ = . In real code we do not want to do that (and in the common mistakes below I will scold you for it), but to keep the example focused on MultiWriter, it is compact.
io.LimitReader: show only the first bytes of the report
Imagine the report is large, and we only need a “snippet” (preview). We will generate the report into a buffer, and then read only a limited part.
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
var report bytes.Buffer
_ = writeTasks(&report, []Task{{ID: 1, Title: "big report", Done: false}})
r := io.LimitReader(&report, 10)
buf := make([]byte, 64)
n, err := r.Read(buf)
fmt.Printf("preview=%q err=%v\n", buf[:n], err)
}
The key point: we read only buf[:n]. And yes, reading from a bytes.Buffer “consumes” data — that is normal, because we treat it like a stream.
io.TeeReader: read a preview and save “what we actually showed”
Sometimes you want not only to limit reading, but also to save what you showed the user so that, for example, on error you can say: “I managed to process this much”.
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("task report line 1\ntask report line 2\n")
var shown bytes.Buffer
r := io.TeeReader(io.LimitReader(src, 12), &shown)
buf := make([]byte, 16)
n, _ := r.Read(buf)
fmt.Printf("shownNow=%q\n", buf[:n]) // shownNow="task report "
fmt.Printf("storedCopy=%q\n", shown.String()) // storedCopy="task report "
}
Here we assembled a small pipeline: first we limited the stream to 12 bytes, then we teed into the shown buffer. As a result, shown contains exactly what actually “flowed through” reading.
7. Common mistakes when working with LimitReader, TeeReader, MultiWriter
When you start assembling I/O pipelines, your brain first gets excited (“like Lego!”), and then tries to mix up the pieces. That is normal: new abstractions always come with a couple of small bumps. The important part is learning to notice the symptoms and quickly return to the contract rules of Reader/Writer: read in chunks, account for n, check errors, and remember that wrappers can have side effects.
Mistake #1: expecting io.TeeReader to “read everything and copy by itself”.
TeeReader does not “move data” without your reading. It copies exactly what you actually read from the returned reader. If you do a single Read for 5 bytes, then the copy will contain at most 5 bytes, even if the source has megabytes.
Mistake #2: ignoring read errors from TeeReader.
The most unpleasant detail of TeeReader is that a write error in w shows up as a read error. From far away it looks weird: “but I’m reading, what does writing have to do with it?” — but writing is part of the tee contract. If you ignore err, you can miss the signal that the “copy” was not saved.
Mistake #3: thinking io.MultiWriter is “everyone or no one”.
MultiWriter can manage to write to the first writer and fail on the second. The result is “the data is present in one place and missing in another”. That is not a bug; that is the nature of the operation. So MultiWriter is good for logs/output, but bad when you need strict atomicity.
Mistake #4: forgetting that io.LimitReader changes end-of-stream behavior.
After the limit is exhausted, you will get io.EOF as if the stream ended. Sometimes a developer interprets this as “the data really ended” and then wonders why the original source still had something left. The source did, but you will not see it through the limiter.
Mistake #5: losing meaning due to the wrong wrapper order.
If you first use TeeReader and then LimitReader, and you expect the copy to contain only the first n bytes, you will most likely get exactly the first n bytes — but because “we only read n”, not because tee limited anything. With wrappers, it helps to say out loud: “what exactly are we limiting?” and “what exactly are we copying?” — and only then write the code.