Example: io.TeeReader
io.TeeReader passes data from an io.Reader to an io.Writer as the data is read.
The caller reads from one io.Reader, while the same bytes are synchronously written to the attached io.Writer.
This is useful for hashing, checksums, byte counting, and other lightweight observations of a stream.
The key property is:
The writer runs as part of the read path.
io.TeeReader does not buffer, copy asynchronously, or provide an independent second reader.
Quick Example
Calculate a SHA-256 digest while processing a file:
The file is consumed only once.
As io.Copy reads from r, each successfully read chunk is also written to hash.
How It Works
Given:
a read behaves conceptually like:
The writer is called synchronously after the underlying reader produces data.
If the writer succeeds, the read result is returned to the caller.
If the writer fails, the read fails with that writer error.
This means the secondary writer is part of the primary read operation's failure and latency path.
Common Use
Hashing a Stream
Hashing is a natural fit because a hash implementation is an io.Writer:
The source is consumed once, and the hash sees the same bytes that pass through the processing pipeline.
Counting Bytes
A writer can also track how many bytes have passed through the stream:
Then:
The counter should remain cheap because it executes synchronously on the read path.
For a counter shared across goroutines, synchronize its state appropriately.
Capturing a Stream
A bytes.Buffer can capture the bytes while another consumer processes them:
This is useful for small diagnostic payloads.
It is not safe as a general-purpose capture mechanism for unbounded input because the buffer grows with the stream.
Important Behavior
The Writer Is Synchronous
Consider:
If slowWriter.Write takes 500 ms, the corresponding Read cannot complete until that write finishes.
So:
A slow tee destination slows the primary reader.
This is usually fine for cheap operations such as hashing and counting.
It is a poor fit for a remote service, slow disk, or other destination whose latency is independent of the primary operation.
Writer Errors Affect the Read
The attached writer is not best-effort.
If it returns an error, TeeReader.Read returns that error.
For example:
An error here may come from either src or auditWriter.
If those failures need different handling or logging, give the two paths distinguishable error context.
Partial Writes Are Not Rolled Back
TeeReader does not provide transactional behavior.
For example, the underlying reader may produce 100 bytes while the attached writer accepts only part of them before returning an error.
The writer cannot undo what it already accepted.
This matters when the writer has persistent side effects or when the caller retries after an error.
Do not interpret a failed TeeReader.Read as meaning that no side effect occurred.
io.EOF Can Arrive With Data
A Reader may return data together with io.EOF.
TeeReader still writes those bytes to the attached writer.
If that write succeeds, the caller can receive the data together with io.EOF.
If the writer fails, the writer error is returned instead.
The practical rule is:
The tee writer must accept the final bytes before the underlying read result can be returned.
Common Mistakes
1. Using It for Asynchronous Logging
This does not make logging asynchronous:
remoteLogger.Write still runs synchronously.
If the remote destination is slow, the main data path is slow.
If logging must be independent from the primary operation, use an explicit asynchronous pipeline instead.
2. Using an Unbounded bytes.Buffer
This can turn a large input into a large memory allocation:
The buffer retains everything that passes through it.
For large or attacker-controlled input, use a bounded or streaming destination instead.
3. Assuming the Tee Is Best-Effort
This assumption is wrong:
With TeeReader, the audit writer is actually part of the same synchronous read path.
If audit failure should not fail the primary operation, TeeReader is usually not the right abstraction.
4. Assuming It Creates a Second Reader
This:
does not create two independent streams.
There is still only one read path.
The writer receives bytes as a side effect of that path.
If the writer stores the bytes in a file or buffer, that destination may later provide another way to read them. That replay capability comes from the destination, not from TeeReader.
io.TeeReader vs. Other Patterns
The important distinction from io.MultiWriter is where the fan-out happens:
TeeReader observes data as it is read.
MultiWriter duplicates data when it is written.
Rule of Thumb
Use
io.TeeReaderwhen a cheap, synchronous side effect should observe the bytes passing through a read path.
Good fits include:
- hashing;
- checksumming;
- counting bytes;
- bounded diagnostic capture.
Avoid it when the secondary operation needs independent latency, failure, retry, or lifecycle semantics.
The simple mental model is:
TeeReaderadds a synchronous side effect toRead.