• English
  • 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:

    package main
    
    import (
    	"crypto/sha256"
    	"fmt"
    	"io"
    	"os"
    )
    
    func main() {
    	f, err := os.Open("data.bin")
    	if err != nil {
    		fmt.Println(err)
    		return
    	}
    	defer f.Close()
    
    	hash := sha256.New()
    	r := io.TeeReader(f, hash)
    
    	if _, err := io.Copy(io.Discard, r); err != nil {
    		fmt.Println(err)
    		return
    	}
    
    	fmt.Printf("SHA-256: %x\n", hash.Sum(nil))
    }

    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:

    r := io.TeeReader(src, dst)

    a read behaves conceptually like:

                 Read(p)
                    |
                    v
              source Reader
                    |
                 data
                    |
            +-------+-------+
            |               |
            v               v
        caller buffer     dst.Write

    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:

    hash := sha256.New()
    
    r := io.TeeReader(src, hash)
    
    if _, err := io.Copy(dst, r); err != nil {
    	return err
    }
    
    digest := hash.Sum(nil)

    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:

    type Counter struct {
    	N int64
    }
    
    func (c *Counter) Write(p []byte) (int, error) {
    	c.N += int64(len(p))
    	return len(p), nil
    }

    Then:

    counter := new(Counter)
    r := io.TeeReader(src, counter)
    
    if _, err := io.Copy(dst, r); err != nil {
    	return err
    }
    
    fmt.Println("bytes processed:", counter.N)

    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:

    var capture bytes.Buffer
    
    r := io.TeeReader(src, &capture)
    
    if _, err := io.Copy(dst, r); err != nil {
    	return err
    }

    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:

    r := io.TeeReader(src, slowWriter)
    
    _, err := io.Copy(dst, r)

    If slowWriter.Write takes 500 ms, the corresponding Read cannot complete until that write finishes.

    So:

    source
      |
      v
    Read
      |
      v
    slowWriter
      |
      v
    caller

    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:

    r := io.TeeReader(src, auditWriter)
    
    if _, err := io.Copy(dst, r); err != nil {
    	return err
    }

    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:

    r := io.TeeReader(src, remoteLogger)
    
    _, err := io.Copy(dst, r)

    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:

    var capture bytes.Buffer
    
    r := io.TeeReader(src, &capture)
    
    _, err := io.Copy(io.Discard, r)

    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:

    primary operation
           |
           +---- optional audit

    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:

    r := io.TeeReader(src, dst)

    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

    RequirementUse
    Observe bytes while they are being readio.TeeReader
    Send the same bytes synchronously to multiple writersio.MultiWriter
    Combine multiple readers sequentiallyio.MultiReader
    Copy a stream to a destinationio.Copy
    Create independent asynchronous consumersExplicit channel/queue pipeline
    Retain a stream for later replayFile, buffer, or another explicit storage mechanism

    The important distinction from io.MultiWriter is where the fan-out happens:

    TeeReader:
    
    Reader ──► caller
       │
       └──────► Writer
    MultiWriter:
    
    caller ──► MultiWriter
                   │
              ┌────┴────┐
              ▼         ▼
             W1        W2

    TeeReader observes data as it is read.

    MultiWriter duplicates data when it is written.

    Rule of Thumb

    Use io.TeeReader when 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:

    TeeReader adds a synchronous side effect to Read.