How to Use io.TeeReader and io.MultiReader

Use io.MultiReader to chain readers sequentially and io.TeeReader to duplicate a stream to a secondary writer.

How to Use io.TeeReader and io.MultiReader

You are building a tool that processes large data streams. You have a configuration header stored in one file and the payload in another. You need to send both to a remote server as a single continuous stream. At the same time, you need to calculate a checksum of the data as it flows, without buffering the entire payload in memory. Loading everything into RAM defeats the purpose of streaming. You need adapters that chain sources and split streams on the fly.

Go's io package provides io.MultiReader to concatenate multiple readers sequentially and io.TeeReader to duplicate a stream to a secondary writer. Both functions return an io.Reader, which means they drop into any function that expects a reader. They are lazy, memory-efficient, and composable.

The io.Reader contract

Both adapters work by implementing the io.Reader interface. The interface defines a single method:

type Reader interface {
    Read(p []byte) (n int, err error)
}

A reader fills the provided byte slice and returns the number of bytes written. It returns io.EOF when the stream ends. io.MultiReader and io.TeeReader return structs that implement this interface. You pass the returned reader to io.Copy, io.ReadAll, or any custom processing loop. The adapters handle the plumbing; you handle the data.

Chaining streams with io.MultiReader

io.MultiReader takes a variadic list of readers and returns a single reader that reads from them in order. It reads from the first reader until that reader returns io.EOF, then switches to the second reader, and so on. The result is a seamless concatenation of the sources.

Think of a playlist. The player outputs audio continuously. When track A finishes, the player automatically starts track B. The listener hears one stream. The player does not copy the tracks into a new file; it just switches sources.

Here's how to chain two string readers into one stream.

package main

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

func main() {
    // Create two independent readers.
    r1 := strings.NewReader("header: ")
    r2 := strings.NewReader("body content")

    // Chain them into a single reader.
    // MultiReader reads r1 until EOF, then switches to r2.
    combined := io.MultiReader(r1, r2)

    // Read the combined stream.
    data, _ := io.ReadAll(combined)
    fmt.Println(string(data))
}

The output is header: body content. The MultiReader struct holds a slice of the input readers. When Read is called, it invokes Read on the current reader. If the current reader returns n == 0 and err == io.EOF, the adapter advances its internal index to the next reader. If all readers are exhausted, it returns io.EOF.

MultiReader does not buffer data. It never allocates a slice to hold the combined result. It streams bytes directly from the underlying sources. This keeps memory usage constant regardless of the total size of the streams.

You can pass a slice of readers by expanding it with the ... operator. The compiler rejects a slice passed directly because MultiReader expects variadic arguments, not a single slice value.

readers := []io.Reader{r1, r2, r3}
// This causes a compile error:
// cannot use readers (variable of type []io.Reader) as io.Reader value in argument
// combined := io.MultiReader(readers)

// Expand the slice to match the variadic signature.
combined := io.MultiReader(readers...)

Passing a nil reader in the list causes a panic when the adapter reaches that reader. The adapter calls Read on the nil interface, which triggers a nil pointer dereference. Validate your reader list before chaining, or filter out nils.

MultiReader chains sources. It never buffers the data.

Duplicating streams with io.TeeReader

io.TeeReader reads from a source reader and writes every byte to a destination writer while returning the data to the caller. It implements a T-splitter pattern. The data flows through the reader, but a copy is sent to the writer simultaneously.

Think of a T-junction in a water pipe. Water flows from the source. The junction splits the flow: one path goes to your tap, the other goes to a water meter. The meter sees every drop, but the water still reaches your tap. In Go, the "meter" is an io.Writer. The "tap" is your code reading the data.

Here's how to duplicate a stream to a logger while processing it.

package main

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

func main() {
    source := strings.NewReader("secret data")
    var logBuffer bytes.Buffer

    // TeeReader reads from source and writes every byte to logBuffer.
    // The returned reader provides the same data to the caller.
    reader := io.TeeReader(source, &logBuffer)

    data, _ := io.ReadAll(reader)
    fmt.Println("Read:", string(data))
    fmt.Println("Logged:", logBuffer.String())
}

The output shows both the read data and the logged data. TeeReader returns a struct holding the source reader and the destination writer. When Read is called, the adapter reads bytes from the source, then calls Write on the destination with those bytes. It returns the bytes to the caller.

The writer's performance dictates the reader's speed. TeeReader calls Write synchronously. If the writer blocks, the reader blocks. If you tee to a slow disk or a network socket, your read loop stalls until the writer catches up. This is a design choice, not a bug. The adapter guarantees that the writer receives the data before the caller proceeds.

If the writer returns an error, TeeReader returns that error immediately. The stream stops. The caller sees the error from the writer as if it came from the reader. This couples the success of the side effect to the success of the main read.

TeeReader duplicates the stream. The writer's performance dictates the reader's speed.

Realistic pattern: Checksums and side effects

A common use case for TeeReader is computing a checksum or hash while streaming data. Hash functions in Go implement io.Writer. You can tee the data to a hash writer, and the hash updates automatically as bytes flow through. This avoids reading the data twice or buffering it.

Here's a realistic pattern: computing a checksum while streaming data without buffering.

package main

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

// ProcessAndHash reads data, computes hash, and returns the hash.
// It demonstrates TeeReader for side-channel computation.
func ProcessAndHash(data string) string {
    source := strings.NewReader(data)
    hash := sha256.New()

    // TeeReader sends data to the hash writer while returning it to the caller.
    // The hash writer updates the digest as bytes flow through.
    reader := io.TeeReader(source, hash)

    // Consume the stream. The hash is computed automatically.
    _, err := io.Copy(io.Discard, reader)
    if err != nil {
        panic(err)
    }

    return fmt.Sprintf("%x", hash.Sum(nil))
}

func main() {
    fmt.Println(ProcessAndHash("verify me"))
}

The hash.Hash interface embeds io.Writer. TeeReader writes bytes to the hash, which updates the internal state. io.Copy drains the reader. The hash is ready when the copy finishes. This pattern scales to gigabytes of data with constant memory usage.

Compute side effects in the stream. Keep memory flat.

Pitfalls and errors

TeeReader couples the reader and writer tightly. If the writer fails, the reader fails. This is intentional. It prevents silent data loss. If you are logging to a file and the disk fills up, the read loop should stop. Continuing would mean data was processed but not logged, violating the audit trail.

If you need the read to succeed even if the writer fails, you must buffer the writes or handle errors asynchronously. TeeReader does not provide error recovery. It propagates the writer error directly.

The compiler enforces type correctness. MultiReader and TeeReader expect io.Reader and io.Writer arguments. Passing a string or a byte slice triggers a type error.

// The compiler rejects this with:
// cannot use "text" (untyped string constant) as io.Reader value in argument
// io.MultiReader("text")

// Wrap values in readers.
io.MultiReader(strings.NewReader("text"))

TeeReader panics if the writer is nil. The adapter calls Write on the writer. A nil writer dereferences to a nil pointer, causing a runtime panic.

// This panics at runtime:
// panic: runtime error: invalid memory address or nil pointer dereference
// io.TeeReader(source, nil)

Validate writers before creating the tee. Use io.Discard if you need a no-op writer for testing or conditional logic.

A failing writer kills the reader. Check errors or use a buffer.

Decision matrix

Use io.MultiReader when you need to concatenate multiple streams sequentially without loading them into memory. Use io.TeeReader when you need to perform a side effect like logging or hashing while consuming a stream. Use io.Pipe when you need to connect a reader and writer across goroutines for asynchronous processing. Use a manual loop with io.Copy when you need fine-grained control over buffering or error handling between segments.

Pick the adapter that matches the data flow. Don't build pipelines manually.

Where to go next