Streaming code often needs to do two things with the same bytes.

A service may need to parse a response while computing its checksum. A file importer may want to decode records while recording exactly what the decoder consumed. A diagnostic tool may want to inspect a stream without first loading the entire input into memory.

A common but wasteful approach is to read everything into a byte slice and then run each operation over that copy. That is simple for small inputs, but it removes the main advantage of streaming: work can begin before the whole input has arrived, and memory usage does not have to grow with the full input size.

Go’s io.TeeReader solves a narrower problem. It wraps an io.Reader, and every byte successfully read through the wrapper is also written to an io.Writer.

The key phrase is read through the wrapper. TeeReader does not independently drain the source. It observes the bytes demanded by the downstream consumer.

That distinction determines both its usefulness and its failure modes.

Think of TeeReader as an inline observer

The simplest mental model is a reader with an observer attached:

source
  |
  v
TeeReader -----> observer writer
  |
  v
consumer

The consumer still controls reading. When it asks the TeeReader for bytes, TeeReader reads from the underlying source and writes those same bytes to the observer before completing the read.

The standard library contract adds two important guarantees:

  • TeeReader does not add internal buffering.
  • If writing to the observer fails, that failure is reported as a read error.

So TeeReader is not a background fan-out mechanism. The observer is part of the read path.

Start with the smallest useful example

Suppose a program wants to read a payload and calculate its SHA-256 digest at the same time.

A hash implements io.Writer, so it can be attached directly:

package main

import (
    "crypto/sha256"
    "fmt"
    "io"
    "strings"
)

func main() {
    source := strings.NewReader("order=4812&status=paid")

    hash := sha256.New()
    reader := io.TeeReader(source, hash)

    data, err := io.ReadAll(reader)
    if err != nil {
        panic(err)
    }

    fmt.Printf("payload: %s\n", data)
    fmt.Printf("sha256: %x\n", hash.Sum(nil))
}

io.ReadAll consumes the stream. Every byte it receives is also passed to the hash.

There is only one read of the source:

source bytes
    |
    +--> consumer receives them
    |
    +--> hash observes the same reads

This avoids a separate second pass over the source.

It does not guarantee lower memory usage in this exact example, because io.ReadAll still stores the complete payload. The streaming benefit appears when the downstream consumer itself processes incrementally.

TeeReader copies only bytes the consumer actually reads

This behavior is easy to misunderstand.

Consider this program:

package main

import (
    "bytes"
    "fmt"
    "io"
    "strings"
)

func main() {
    var observed bytes.Buffer

    reader := io.TeeReader(
        strings.NewReader("abcdef"),
        &observed,
    )

    buf := make([]byte, 3)
    n, err := reader.Read(buf)
    if err != nil {
        panic(err)
    }

    fmt.Printf("consumer: %q\n", buf[:n])
    fmt.Printf("observer: %q\n", observed.String())
}

After one three-byte read, the observer contains the same bytes returned by that read. It does not yet contain the rest of the source.

Conceptually:

source:   abcdef
read:     abc
observed: abc
unread:      def

If the consumer stops at that point, def is never copied to the observer.

This matters whenever the observer is supposed to represent the complete source. A TeeReader can only guarantee that it observes the complete source when the downstream consumer reads the complete source.

Hash while decoding instead of buffering first

A more realistic use is computing a digest while a decoder consumes a stream.

Suppose each line contains one event:

created
validated
stored

The program can hash exactly the bytes consumed by a buffered scanner:

package main

import (
    "bufio"
    "crypto/sha256"
    "fmt"
    "io"
    "strings"
)

func main() {
    source := strings.NewReader("created\nvalidated\nstored\n")

    hash := sha256.New()
    reader := io.TeeReader(source, hash)

    scanner := bufio.NewScanner(reader)

    for scanner.Scan() {
        fmt.Println("event:", scanner.Text())
    }

    if err := scanner.Err(); err != nil {
        panic(err)
    }

    fmt.Printf("sha256: %x\n", hash.Sum(nil))
}

The scanner decides how much data to request and when. TeeReader forwards those reads to the hash.

Because the scanner reads until EOF in this example, the digest covers the complete source.

If parsing intentionally stops early, the digest covers only the bytes read before that stop.

That is often the correct behavior for an observer of parser consumption, but it is wrong if the application claims to have hashed the entire input.

Full-stream verification requires full-stream consumption

Imagine a protocol in which a caller provides a stream plus an expected digest.

This approach looks plausible:

hash := sha256.New()
reader := io.TeeReader(body, hash)

if err := decodeOneObject(reader); err != nil {
    return err
}

if !matchesExpected(hash.Sum(nil)) {
    return errors.New("digest mismatch")
}

The problem is that decodeOneObject may stop as soon as it has decoded one object. Additional bytes can remain unread.

The digest then proves only something about the consumed prefix.

If the security or integrity rule requires a digest of the complete body, make full consumption part of the operation. One option is to design the decoder so trailing data is rejected and EOF is reached before the digest is accepted.

Another option is to finish draining the stream after decoding when the protocol permits trailing bytes:

if err := decodeOneObject(reader); err != nil {
    return err
}

if _, err := io.Copy(io.Discard, reader); err != nil {
    return err
}

if !matchesExpected(hash.Sum(nil)) {
    return errors.New("digest mismatch")
}

This distinction is not specific to hashing. Logging, metrics, byte counting, compression statistics, and other observers see only the bytes that travel through the TeeReader.

The observer is synchronous backpressure

TeeReader has no internal buffer.

That means the observer’s Write happens as part of the consumer’s Read.

Conceptually:

consumer calls Read
        |
        v
read source bytes
        |
        v
write observer bytes
        |
        v
return from Read

If the observer takes 200 milliseconds to write, the consumer’s read is delayed by that write.

This property is useful when the observer must remain exactly synchronized with consumption. It can also be an operational cost.

For example, this is risky on a latency-sensitive request path:

reader := io.TeeReader(response.Body, slowRemoteLogger)

A slow logging destination can now slow response processing.

The correct conclusion is not that TeeReader is slow. The cost depends on the attached writer. A fast in-memory hash usually has very different latency characteristics from a remote network writer.

Treat the observer as work that is directly in the critical read path.

Writer failures become read failures

The standard library deliberately reports writer errors as read errors.

Suppose an observer cannot accept data:

type failingWriter struct{}

func (failingWriter) Write(p []byte) (int, error) {
    return 0, errors.New("audit sink unavailable")
}

Then a consumer reading through:

reader := io.TeeReader(source, failingWriter{})

can receive audit sink unavailable from Read.

This is an important design choice because TeeReader promises that bytes read through it are matched by writes to the observer. If the observer cannot keep up correctly, the wrapper cannot silently claim success.

For callers, that means a read error may originate from either side:

underlying Reader error
        or
observer Writer error
        |
        v
TeeReader.Read returns error

When error origin matters operationally, wrap the writer with context-specific errors.

For example:

type auditWriter struct {
    dst io.Writer
}

func (w auditWriter) Write(p []byte) (int, error) {
    n, err := w.dst.Write(p)
    if err != nil {
        return n, fmt.Errorf("write audit stream: %w", err)
    }
    return n, nil
}

The consumer still receives a read error, but logs and error inspection can identify the observer as the source.

Short writes are also failures

The io.Writer contract requires a writer that returns fewer than len(p) bytes to also return a non-nil error.

Well-behaved writers follow that rule.

TeeReader depends on the writer contract rather than inventing separate recovery logic. A broken custom writer that reports a short write with nil violates the io.Writer interface expectations and can produce confusing behavior.

When writing custom observers, follow the normal rule:

func (w *myWriter) Write(p []byte) (int, error) {
    // If fewer than len(p) bytes are accepted,
    // return a non-nil error.
}

For standard-library writers such as hash implementations and bytes.Buffer, this contract is already handled.

Use an io.Writer that represents the observation

TeeReader becomes especially useful because many Go components already implement io.Writer.

Compute a checksum

Hashes are natural observers:

hash := sha256.New()
reader := io.TeeReader(source, hash)

Count consumed bytes

A small writer can count bytes without retaining them:

type byteCounter int64

func (c *byteCounter) Write(p []byte) (int, error) {
    *c += byteCounter(len(p))
    return len(p), nil
}

Then:

var count byteCounter
reader := io.TeeReader(source, &count)

The counter reports bytes consumed through the wrapper, not necessarily the total source size.

Capture a small stream for tests

A buffer is useful when the input is known to be bounded:

var observed bytes.Buffer
reader := io.TeeReader(source, &observed)

Do not apply this casually to unbounded or attacker-controlled inputs. bytes.Buffer grows as data is written, so observing a very large stream this way can consume correspondingly large memory.

Bound diagnostic capture explicitly

A common debugging requirement is:

Keep the first few kilobytes for diagnostics while processing the full stream.

Writing the entire stream into a buffer is unnecessary and potentially dangerous.

Use a writer that stores only a bounded prefix.

For example:

type prefixWriter struct {
    dst []byte
    max int
}

func (w *prefixWriter) Write(p []byte) (int, error) {
    if len(w.dst) < w.max {
        remaining := w.max - len(w.dst)
        if remaining > len(p) {
            remaining = len(p)
        }
        w.dst = append(w.dst, p[:remaining]...)
    }

    // We intentionally accept the full write even when we retain only
    // a prefix. The observer is sampling, not rejecting the stream.
    return len(p), nil
}

Usage:

capture := &prefixWriter{max: 4096}
reader := io.TeeReader(source, capture)

The critical detail is the return value. The writer returns len(p) because it intentionally accepts the observation while choosing to retain only part of it.

Returning only the number of bytes stored would incorrectly signal a short write.

For production diagnostics, also consider whether captured bytes may contain credentials, personal data, tokens, or other sensitive content. Bounding memory does not solve data-exposure risk.

TeeReader does not create an independent copy of the source

It is tempting to think of TeeReader as producing two readers.

It does not.

The returned value is one io.Reader. The second side is an io.Writer.

This means you cannot use it when two independent consumers need to read at their own pace.

For example, TeeReader cannot directly model:

source
  |
  +--> parser A reads whenever it wants
  |
  +--> parser B reads whenever it wants

The observer must accept writes synchronously as the primary reader advances.

If two consumers each need a reader interface and independent pacing, you need a different design: buffering, a pipe-based fan-out, durable storage, separate source reads, or application-specific coordination.

Those designs have their own memory, blocking, and failure semantics.

TeeReader and MultiWriter solve opposite sides of the flow

io.MultiWriter is sometimes confused with io.TeeReader.

They duplicate different operations.

TeeReader starts with a reader:

Reader
  |
TeeReader
  | \
  |  +--> Writer observer
  v
Reader consumer

MultiWriter starts with writes:

producer
   |
   v
MultiWriter
 /       \
Writer A  Writer B

Choose based on who owns the data flow.

If code already receives an io.Reader and you want to observe what a consumer reads, TeeReader fits naturally.

If code already produces bytes by calling Write and you want every write sent to multiple destinations, MultiWriter is the more direct abstraction.

TeeReader and io.Copy can stream to two destinations

Sometimes the primary operation is simply copying a source to a destination while also observing it.

You can combine TeeReader with io.Copy:

hash := sha256.New()
reader := io.TeeReader(source, hash)

written, err := io.Copy(destination, reader)
if err != nil {
    return err
}

fmt.Printf("copied %d bytes\n", written)
fmt.Printf("sha256: %x\n", hash.Sum(nil))

Here the destination controls consumption through io.Copy, and the hash sees the same bytes that pass through the wrapper.

If destination fails after receiving only part of the stream, io.Copy stops. The hash may therefore represent more than the destination successfully committed if the failing destination accepted bytes before returning its error.

That boundary matters when the digest is supposed to describe data durably stored by the destination. TeeReader observes reads, not transactional commitment by the downstream writer.

If storage success and digest publication must be atomic from the application’s perspective, coordinate those operations at a higher level.

Reader buffering can change when observations happen

A buffered consumer may request more bytes than it immediately exposes to application code.

For example, bufio.Reader can fill its internal buffer in larger chunks.

If it reads 4 KiB from a TeeReader but the application consumes only the first line from that buffer, the observer has already seen all 4 KiB because those bytes were already read from the wrapper.

This is still correct according to TeeReader’s contract.

The important distinction is:

bytes read from TeeReader
    != always
bytes already processed by application logic

When the observer must correspond to semantic processing rather than physical reads, attach the observation at the semantic layer instead.

For example, increment an event counter after an event is successfully decoded instead of counting raw bytes with TeeReader.

Do not use TeeReader as a security boundary by itself

A TeeReader can help calculate a digest or capture evidence about consumed bytes, but it does not authenticate data on its own.

A checksum such as SHA-256 can detect accidental or unexpected changes when compared against a trusted expected value. It does not prove who produced the data unless the surrounding protocol establishes authenticity through a suitable mechanism such as a digital signature or message authentication code.

Likewise, copying bytes to an audit writer does not guarantee durable audit storage. The writer’s semantics determine what “written” actually means.

Use TeeReader for byte-flow composition. Keep integrity, authentication, durability, authorization, and retention guarantees at the layers that own those responsibilities.

Avoid accidental secret logging

One attractive use of TeeReader is request or response logging:

reader := io.TeeReader(body, logWriter)

That can be unsafe if the stream may contain passwords, session tokens, API keys, personal data, or uploaded documents.

Raw byte logging also makes later redaction difficult because sensitive values may appear in encodings the logger does not understand.

Prefer structured logging after parsing when you know which fields are safe to record.

If raw capture is necessary for a controlled diagnostic workflow, bound its size, restrict access, define retention, and avoid enabling it globally by default.

Concurrency safety comes from the wrapped components

The io package does not generally promise that arbitrary readers and writers are safe for concurrent use.

TeeReader itself does not add synchronization that turns unsafe components into safe ones.

If multiple goroutines call Read on the same TeeReader, behavior depends on the concurrency properties of the underlying reader and writer, and the resulting byte ordering may not match the logical operation you intended.

A clearer pattern is usually to give one goroutine ownership of a sequential stream and parallelize work after records or chunks have been separated safely.

Use concurrency because the data model supports it, not because the wrapper happens to be small.

Common mistakes

Assuming the observer receives the entire source automatically

It receives only bytes read through the TeeReader.

If the consumer stops early, the observer stops early.

Attaching a slow network writer without considering latency

Observer writes are synchronous. A slow writer slows reads.

Capturing an unbounded stream in memory

A bytes.Buffer retains everything written to it. Bound diagnostic capture when input size is not strictly controlled.

Treating observed bytes as processed records

Buffered consumers can read ahead. Raw bytes may be observed before application logic processes them.

Using TeeReader when two independent readers are required

The secondary side is a writer, not another reader, and it cannot consume at its own pace.

Ignoring writer-originated read errors

An error returned from Read may come from the observer writer. Add context when that distinction matters.

When TeeReader is the right tool

Use io.TeeReader when:

  • one consumer already owns an io.Reader;
  • another operation can be expressed as an io.Writer;
  • the observer should see exactly the bytes read through the wrapper;
  • synchronous observation is acceptable;
  • a writer failure should stop the read path.

Typical examples include hashing, byte counting, bounded diagnostic capture, and test instrumentation.

Choose a different design when the second consumer needs independent pacing, when observation must happen asynchronously, when you need semantic rather than raw-byte events, or when the observer must receive the complete source regardless of how much the primary consumer reads.

Conclusion

io.TeeReader is small, but its semantics are precise.

It does not duplicate a whole stream in the background. It copies each byte that a consumer reads to an observer writer, synchronously and without internal buffering. That makes it a clean fit for hashing, counting, and other inline observations.

The most important design question is not merely whether you need “two copies” of the data. Ask what exactly must be observed.

If the answer is the bytes this consumer reads, TeeReader is often the right abstraction. If the answer is the complete source, independent consumers, or semantic processing events, use a design whose guarantees match that requirement.