Example: io.TeeReader
Introduction
io.TeeReader connects an io.Reader to an io.Writer so that every byte successfully read from the source is synchronously written to the destination. The caller still interacts with a single io.Reader; the copied data is produced as a side effect of each Read call.
This makes io.TeeReader useful when data must be observed or processed while it is being consumed: calculating a hash, computing a checksum, tracking progress, or recording a copy of a stream. It is not a buffering mechanism, an asynchronous pipeline, or a transactional copy operation.
Use it when the secondary operation should be part of the same read path. Do not use it when the secondary destination has an independent lifecycle, must not slow down the reader, or may fail independently without affecting the primary operation.
Quick Example
The following example calculates a SHA-256 digest while the file is being read. The file is read only once.
The important property is that hash.Write happens as part of reading from r. There is no second pass over the file.
How It Works
Conceptually, a call to:
performs two operations:
- Read bytes from the underlying
Readerintop. - Write those bytes to the configured
Writer.
The Writer receives only the bytes that were successfully read.
A simplified model is:
The writer is not an independent consumer. It executes synchronously during the Read operation.
This distinction is fundamental: io.TeeReader couples the writer's execution to the reader's progress.
Even when the attached writer returns an error, bytes already read from the underlying reader are still returned to the caller along with that error.
A Subtle Partial-Write Detail
The standard library implementation contains an easily overlooked detail:
The n and err declared by t.w.Write are scoped to the if statement and therefore shadow the named return values.
This matters when the attached writer performs a partial write.
Suppose the underlying reader successfully places 100 bytes into p, but the writer accepts only 30 bytes before returning an error. TeeReader.Read returns:
The underlying reader actually produced 100 bytes, and all 100 bytes are already present in p, but the caller is told that only 30 bytes were read.
This is an unusual but important failure state. The caller must follow the io.Reader contract and treat the returned n as the number of bytes that the TeeReader successfully processed, rather than assuming that the entire underlying read was committed through the tee.
io.EOF With Data
The underlying reader may return data together with io.EOF:
TeeReader still writes those n bytes to the attached writer before returning.
If the writer succeeds, the same call returns:
Thus the caller observes the final data and the EOF indication together.
If the writer instead fails, the writer error replaces the underlying io.EOF for that call.
The practical rule is simple: the attached writer must successfully accept the bytes before the underlying read result, including io.EOF, can reach the caller.
Usage Scenarios
1. Calculate a Hash While Reading
A common use case is calculating a digest while consuming a stream.
This is preferable to reading the source once to calculate the hash and then reading it again to process the data.
The source only needs to be consumed once.
2. Calculate a Checksum During Processing
The same pattern works for checksum implementations that satisfy io.Writer.
The checksum calculation follows the actual bytes that passed through the read path.
3. Track Progress
A writer can also be used to count bytes.
Then:
This is useful when progress is naturally defined as bytes consumed from the stream.
The writer should normally keep Write cheap. If the same writer can be called concurrently, its state must also be synchronized appropriately. For a shared byte counter, for example, sync/atomic can be used instead of an unsynchronized int64 increment.
4. Audit or Diagnostic Capture
io.TeeReader can be useful when a stream needs to be captured for diagnostics while it is being processed.
This should be used carefully.
A bytes.Buffer retains everything written to it. If the source is large or attacker-controlled, memory consumption grows with the amount of data consumed.
For unbounded streams, a bounded or streaming destination is usually safer.
Production Boundaries
The Writer Is on the Critical Read Path
The most important production property of io.TeeReader is that the destination writer is synchronous.
Consider:
If slowWriter.Write takes 500 ms, the corresponding Read cannot complete until that write completes.
The data path effectively becomes:
The slowest operation determines progress.
io.TeeReader therefore provides a form of backpressure coupling between the primary reader and the secondary writer.
This is usually desirable for hashes and counters because those operations are cheap and deterministic. It can be problematic for network logging, remote storage, or other potentially slow destinations.
No Internal Synchronization
io.TeeReader itself provides no synchronization.
If multiple goroutines read from the returned io.Reader concurrently, the underlying Reader and attached Writer must support concurrent use as well. Any mutable state maintained by the writer must also be synchronized.
More importantly, concurrent reads do not turn TeeReader into a concurrent pipeline. It simply forwards the calls to the underlying reader and writer without adding its own locking or ordering guarantees.
In most ordinary streaming code, one goroutine should own the read path unless the underlying components explicitly support concurrent access.
Writer Errors Propagate to the Primary Read
The secondary writer is not best-effort.
If the writer returns an error, TeeReader returns that error from Read.
For example:
The application must therefore treat the writer as part of the read operation's failure domain.
This is different from an asynchronous logging system where failure to record an event may be deliberately isolated from the primary operation.
Partial Progress Is Possible
A Read may return data together with an error.
TeeReader adds another layer of partial failure because the underlying reader and attached writer may disagree about how much data was successfully processed.
For example:
The caller's buffer may already contain all 100 bytes produced by the underlying reader, but the TeeReader reports only 30 bytes because only 30 bytes were successfully written by the attached writer.
io.TeeReader provides no rollback. Data already accepted by the writer remains accepted even if a later write fails.
Errors Do Not Identify Their Source
When you get an error from TeeReader, it may have originated from either the underlying reader or the attached writer.
The error itself does not identify the source.
If the application needs to distinguish these failure paths, wrap the reader or writer explicitly so the error can be classified before it reaches the caller.
For example, an application can use distinct error types or wrapping conventions for source-read failures and tee-write failures.
This can make production diagnosis substantially easier when both sides participate in the same Read error path.
It Does Not Make the Stream Replayable
A common misunderstanding is to treat TeeReader as a way to "save" a stream for later use.
This does not create a second reader.
The bytes are written to buf as they are consumed. Only the writer's own storage determines whether those bytes can later be read again.
If the writer is:
the observed data is gone.
If the writer is a file, the file provides the persistent copy.
If the writer is a bytes.Buffer, the data remains in memory.
TeeReader itself provides no replay capability.
Common Mistakes
Mistake 1: Treating TeeReader as Asynchronous Logging
WRONG
This does not send logging work to a background worker.
remoteLogger.Write executes synchronously as part of the read operation.
If the remote destination is slow, the main data path is slow.
BETTER
Use a local, bounded, cheap writer on the critical path and decouple expensive processing explicitly when independent failure and latency are required.
The important point is that the decoupling must be designed by the application; io.TeeReader does not provide it.
Mistake 2: Using an Unbounded Buffer
WRONG
For an unbounded input, capture grows with the amount of data consumed.
A large upload can therefore become a large in-memory allocation.
BETTER
Bound the amount of data that may be retained:
If the requirement is to capture an entire large stream, write it to an appropriate streaming destination instead of retaining it in memory.
Mistake 3: Assuming the Writer Is Best-Effort
WRONG
The error may have originated from auditLog, not from src.
When you get an error from TeeReader, it may have originated from either the underlying reader or the attached writer. The error itself does not identify the source.
BETTER
Design the error semantics explicitly.
If audit data is mandatory, propagate the failure.
If audit data is optional, io.TeeReader may be the wrong abstraction; use an explicitly isolated mechanism instead.
If the application must distinguish source-read failures from tee-write failures, add explicit error classification around the participating components.
Mistake 4: Doing Expensive Work in Write
WRONG
Every read is now coupled to sendToRemoteService.
This can introduce network latency, connection failures, retries, contention, and unpredictable throughput into the primary data path.
BETTER
Keep Write cheap and bounded when the writer is attached directly to a production read path.
If expensive processing is required, introduce an explicit queue or asynchronous pipeline with defined buffering, backpressure, shutdown, and failure semantics.
What io.TeeReader Guarantees — and What It Does Not
This table captures the key engineering boundary.
io.TeeReader is a stream observation primitive, not a general-purpose data replication mechanism.
When to Use It
Use io.TeeReader when:
- the secondary operation naturally follows the bytes being read;
- the writer is cheap enough to run synchronously;
- writer failure should affect the read operation;
- the data only needs to be consumed once;
- hashing, checksumming, counting, or bounded capture is required.
Typical examples include:
- computing a cryptographic digest;
- calculating a checksum;
- tracking bytes processed;
- writing a local copy while processing a stream;
- collecting bounded diagnostic data.
When Not to Use It
Avoid io.TeeReader when:
- the secondary destination may be significantly slower than the reader;
- secondary processing must not block the primary path;
- writer failures should not fail the primary operation;
- the secondary operation requires retries independent of the reader;
- the data must be consumed by multiple independent consumers;
- an unbounded in-memory copy would be created.
In these cases, an explicit pipeline is usually clearer:
That architecture introduces more machinery, but it also makes buffering, concurrency, failure handling, and lifecycle semantics explicit.
A Useful Mental Model
The easiest way to reason about io.TeeReader is:
"Whenever the caller successfully reads bytes, synchronously pass those bytes to another writer."
Not:
"Read the data and make a copy in the background."
Not:
"Create another independent reader."
And not:
"Log the stream without affecting the main operation."
Those distinctions matter in production.
Bad Mental Models
Do not think of io.TeeReader as:
- A background stream copier.
- An independent stream consumer.
- A mechanism that isolates side-channel write failures from the main I/O path.
- A built-in memory buffer for stream caching.
The correct mental model is simpler:
io.TeeReaderis synchronous observation attached to the read path.
It is useful precisely because it adds very little machinery. But that simplicity also means its latency, error, and lifecycle semantics remain tightly coupled to the underlying Read call.
Summary
io.TeeReader is a small abstraction with a very specific contract: it observes bytes as they are read and synchronously writes those bytes to another io.Writer.
Its strength is simplicity. Hashing, checksumming, byte counting, and bounded capture can all be implemented without reading the source twice or building a custom streaming pipeline.
Its limitation is equally important: the writer is part of the read path. Writer latency and writer errors therefore propagate into the primary operation, and there is no buffering, asynchronous execution, or rollback.
For cheap, deterministic side effects, io.TeeReader is an excellent fit.
For independent consumers, slow destinations, best-effort processing, or failure isolation, it is usually the wrong abstraction.
The production decision is therefore not:
"Can
io.TeeReadercopy these bytes?"
It can.
The real question is:
"Should this secondary operation be allowed to control the progress and failure of the primary read?"