How do you write an io.Writer wrapper that counts bytes without touching call sites?
answer
- one method is all you have to satisfy
- state means a pointer receiver
- add what landed, not what you offered
- hand back the inner n and err untouched
- the wrapper hides the destination's other interfaces
basics
~20 sDeclare a struct holding the inner io.Writer plus a counter, and give it a pointer-receiver Write that forwards the slice, adds the returned n to the counter, and returns that same n and error unchanged. Callers keep passing an io.Writer.
solid answer
~50 sBecause `io.Writer` has exactly one method, a wrapper is a struct that holds the destination and whatever state you are gathering — a byte count, a digest, a progress callback — with a `Write` that delegates. The rules that make it safe: use a pointer receiver so the counter you update is the one the caller reads; add the `n` the inner writer actually returned, not `len(p)`; and return that same `n` and `err` verbatim rather than normalising them, because a copy loop uses the pair to decide whether the transfer is intact. Never report `len(p)` with a nil error after a short inner write — that hides data loss. If the count is read from another goroutine while the transfer runs, keep it in a `sync/atomic` type. And be aware that wrapping erases any extra interfaces the destination had, so a wrapped `*os.File` no longer offers its `ReadFrom` fast path.
code
go · 10 linestype countingWriter struct {
w io.Writer
n int64
}
func (c *countingWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.n += int64(n) // what actually landed, not len(p)
return n, err // never repair the pair
}go deeper
Be ready to write the four-line struct and Write method from memory: hold the inner writer, call it, record the result, return it. Knowing that the caller's type stays io.Writer is the point of the exercise.
Explain why the receiver must be a pointer, why you add the returned n rather than len(p), and what the Write contract obliges you to return when the inner writer accepts only part of the slice.
Demonstrate that you would test it with a stub that short-writes on demand, that you would reach for atomics the moment a second goroutine reads the count, and that you know wrapping can cost a fast path on a file or socket destination.
Own the decision of what instrumentation is worth: inline wrappers add latency and can erase kernel fast paths, so weigh a per-byte counter against sampling, and decide once for the codebase rather than per call site.
## Instrumenting a transfer nobody wants rewritten The practical version of this problem: a transfer already exists, its call sites are spread across the codebase, and you need to know how many bytes it moved — or what its checksum was, or how far along it is. Since every one of those call sites is typed as `io.Writer`, you can slide a layer in between without any of them noticing. That is the whole value of a one-method interface. ### The shape A wrapper is a struct with two parts: the next writer in the chain, and your state. ```go type countingWriter struct { w io.Writer n int64 } func (c *countingWriter) Write(p []byte) (int, error) { n, err := c.w.Write(p) c.n += int64(n) return n, err } ``` That is the entire pattern, and every line of it is load-bearing. ### Rule one: pointer receiver If `Write` had a value receiver, each call would update a copy of the struct and the counter the caller inspects would stay at zero. The wrapper carries mutable state, so it must be used as `*countingWriter` — which also means the caller has to pass `&countingWriter{w: dst}`, not the value. Forgetting this compiles cleanly and produces a counter that is always zero, which is a frustrating bug precisely because nothing looks wrong. ### Rule two: count what actually landed Add the `n` returned by the inner writer, not `len(p)`. They differ exactly when something went wrong, and those are the cases where an accurate byte count matters most. Counting `len(p)` turns your instrumentation into a report of what you *attempted*, which is the number you least want when you are trying to work out how much of a file reached the far end. ### Rule three: forward the result faithfully The `io.Writer` contract says `Write` must return the number of bytes consumed and, if `n < len(p)`, a non-nil error explaining why. Copy loops rely on that: a standard copy compares what it read against what the destination reported and, if the destination took fewer bytes while claiming success, reports `io.ErrShortWrite`. So a wrapper must not repair the numbers. Returning `len(p)` with a nil error after a short inner write makes a truncated transfer look successful — the worst possible outcome, because the failure is now invisible. Equally, do not invent errors the inner writer did not return: your job is observation, not policy. The way to prove the wrapper obeys this is a stub destination in a test: a tiny type whose `Write` returns a short count, or an error, on demand. Drive the wrapper with it and assert that the numbers come back out unchanged and that the counter matches what the stub actually accepted. That stub is more valuable than any amount of staring at the code, because short writes almost never occur in the happy-path environments where you develop. ### Rule four: mind who reads the counter If the count is only inspected after the transfer finishes, on the same goroutine, a plain field is fine. If a progress reporter reads it while the copy is still running, that is a data race — use `atomic.Int64` and its `Add`/`Load` methods instead of a bare field. The race detector will find this one if you exercise it, and it is a very common defect in progress-bar wrappers. ### The cost you may not expect Wrapping is not free in one specific way: it hides the destination's other interfaces. Many concrete destinations implement more than `io.Writer`. An `*os.File` and a TCP connection both implement `io.ReaderFrom`, which lets a copy hand the whole transfer down to the kernel instead of shuttling chunks through a user-space buffer. A copy checks for that interface on the destination it is given — and the destination it is given is now your wrapper, which implements only `io.Writer`. The transfer still works, but a large copy can lose a substantial fast path. If that matters, you can re-expose the capability by implementing `ReadFrom` on the wrapper: delegate to the inner writer's `ReadFrom` when it has one, add the returned count to your counter, and fall back to the plain path otherwise. Whether it is worth the extra code is a measurement question — for a log-sized stream it never is, for a file server it can be the difference that shows up on a profile. ### Composing rather than reimplementing Finally, do not write a wrapper for something the standard library already gives you. Mirroring to two destinations is `io.MultiWriter`. Observing a *read* stream instead of a write stream is `io.TeeReader`. Hand-written wrappers are for state the library cannot know about: your counter, your progress callback, your rate limiter, your error-tolerant sink.
- Wrapping an *os.File destination made a large copy noticeably slower. Why?An `*os.File` implements `io.ReaderFrom`, so a copy can push the transfer down into the kernel rather than moving chunks through a user-space buffer. The copy looks for that method on the destination it was handed, which is now your wrapper — and your wrapper only implements `io.Writer`, so the fast path disappears. You can restore it by implementing `ReadFrom` on the wrapper and delegating when the inner writer supports it.
- A progress reporter reads the counter while the copy is still running. What breaks?It is a data race: `Write` mutates the field on the copying goroutine while the reporter loads it on another, with no synchronisation. Build it with `-race` and the detector will flag it. The fix is to hold the count in an `atomic.Int64` and use `Add` and `Load`, which keeps the wrapper allocation-free and lock-free while making concurrent reads well defined.
- How would you test that the wrapper handles a short write correctly?With a stub destination whose `Write` returns a partial count, or an error, on command. Drive the wrapper with it and assert three things: the wrapper returned exactly the inner `n` and `err`, the counter advanced by the accepted bytes only, and nothing was invented on either side. Short writes essentially never happen against a local file, so a stub is the only reliable way to exercise the path.
saying these in an interview costs you the question
- Uses a value receiver and wonders why the count stays zero
- Adds len(p) to the counter instead of the returned n
- Returns len(p) with a nil error after a short inner write
- Reads the counter from another goroutine without atomics
- Assumes wrapping preserves everything the destination could do