• English
  • Example: io.MultiReader

    io.MultiReader combines multiple io.Reader values into a single sequential input stream. It allows callers to consume several data sources in order without manually switching between Readers.

    It solves the problem of sequential stream composition.

    For example, an application can combine:

    • An in-memory protocol header + a file payload
    • Metadata + a large file
    • Multiple file segments
    • Multiple in-memory buffers
    • A dynamically generated prefix + a network stream

    into a single io.Reader.

    The core value of io.MultiReader is:

    It composes the order in which data is read, not the data itself.

    The core trap is:

    io.MultiReader only manages sequential reading. It does not manage concurrency, error recovery, or the lifecycle of underlying resources.

    Quick Start

    The simplest scenario: combine two Readers into one sequential Reader.

    package main
    
    import (
    	"fmt"
    	"io"
    	"strings"
    )
    
    func main() {
    	r := io.MultiReader(
    		strings.NewReader("header:"),
    		strings.NewReader("body"),
    	)
    
    	data, err := io.ReadAll(r)
    	if err != nil {
    		fmt.Printf("failed to read: %v\n", err)
    		return
    	}
    
    	fmt.Println(string(data))
    }

    Expected output:

    header:body

    The caller sees one continuous input stream:

    header:body

    But the data is actually provided by two independent Readers in sequence.


    Core Contract

    io.MultiReader(readers...) returns an io.Reader that consumes the supplied Readers sequentially.

    Conceptually:

    Reader 1 ──EOF──▶ Reader 2 ──EOF──▶ Reader 3 ──EOF──▶ EOF

    For the caller, multiple independent Readers appear as one continuous input stream.

    If no Readers are provided, io.MultiReader() returns an empty Reader that always returns io.EOF.

    If an input Reader immediately returns io.EOF, MultiReader skips it and continues with the next Reader without exposing an intermediate EOF to the caller.

    Success

    When MultiReader returns err == nil, it only means that the current Read operation successfully returned some data.

    It does not mean that:

    • The current Reader has been exhausted.
    • All Readers have been consumed.
    • The combined stream has reached EOF.

    Only after all underlying Readers have returned io.EOF will MultiReader return the final io.EOF to the caller.

    An intermediate io.EOF is consumed internally and treated as the boundary for moving to the next Reader.

    Failure and Boundary Behavior

    SituationBehavior
    Current Reader returns data and nilReturn the data to the caller
    Current Reader returns data and a non-EOF errorReturn the data and error as provided by the underlying Reader, then stop reading
    Current Reader returns io.EOF and more Readers remainMove to the next Reader
    All Readers are exhaustedReturn io.EOF

    The core rule is:

    io.MultiReader treats only io.EOF as a normal boundary between Readers. Other errors are propagated.

    It does not:

    • Retry a failed Reader.
    • Skip a failed Reader.
    • Continue with later Readers after a non-EOF error.
    • Read multiple Readers concurrently.
    • Close underlying resources.

    When to Use

    Sequentially Combine Multiple Data Sources

    Use io.MultiReader when several data sources logically form one continuous sequential input stream.

    Typical scenarios include:

    • In-memory header + file payload
    • Metadata + large file
    • Multiple file segments
    • Dynamically generated prefix + network stream
    • Multiple independent buffers

    Why: io.MultiReader combines multiple data sources without constructing a new complete []byte. The resulting stream remains fully incremental.

    For example:

    r := io.MultiReader(
    	strings.NewReader("type=text\n\n"),
    	file,
    )

    The caller only needs to work with one io.Reader and does not need to manually switch between the underlying data sources.


    When Not to Use

    When Multiple Sources Must Be Read Concurrently

    io.MultiReader strictly consumes Readers in sequence.

    If the first Reader blocks, later Readers are not accessed:

    Reader A
    
       │ blocking
    
    Reader B  ← never reached
    Reader C  ← never reached

    It is therefore not suitable when multiple independent data sources need to make progress concurrently.

    Risk: If an earlier network connection or other Reader blocks for a long time, the entire combined stream is blocked and later Readers cannot make progress.

    Use Instead: Depending on the application, use goroutines, channels, io.Pipe, or an application-level scheduling mechanism to explicitly control concurrency and cancellation.

    The key distinction is:

    io.MultiReader is a sequential composition primitive, not a concurrency primitive.


    Quick Reference

    Question: Does io.MultiReader read multiple Readers concurrently?

    Answer: No. It consumes Readers strictly in the order provided. The next Reader is accessed only after the current Reader returns io.EOF.

    Question: If an intermediate Reader returns an error, will io.MultiReader continue with the next Reader?

    Answer: No. io.EOF is treated as a normal boundary between Readers. Other errors are returned immediately, and subsequent Readers are not read.

    Question: Does io.MultiReader automatically close underlying io.ReadCloser values?

    Answer: No. io.MultiReader itself implements only io.Reader. It does not wrap or proxy the Close methods of underlying resources.

    Key Point: io.MultiReader provides sequential stream composition, not concurrent scheduling, error recovery, or resource management.


    Production Scenario

    Streaming a Generated Header Before a Large File

    Problem: An application needs to send a dynamically generated header followed by a large file.

    If the header and the entire file are first combined into a single []byte:

    header + entire file

    memory usage grows with the size of the file.

    io.MultiReader allows the application to combine the two data sources while preserving streaming behavior:

    package main
    
    import (
    	"fmt"
    	"io"
    	"os"
    	"strings"
    )
    
    func main() {
    	file, err := os.CreateTemp("", "payload-*.txt")
    	if err != nil {
    		fmt.Printf("failed to create file: %v\n", err)
    		return
    	}
    	defer os.Remove(file.Name())
    	defer file.Close()
    
    	if _, err := file.WriteString("hello from file"); err != nil {
    		fmt.Printf("failed to write file: %v\n", err)
    		return
    	}
    
    	if _, err := file.Seek(0, io.SeekStart); err != nil {
    		fmt.Printf("failed to seek file: %v\n", err)
    		return
    	}
    
    	r := io.MultiReader(
    		strings.NewReader("type=text\n\n"),
    		file,
    	)
    
    	// In a real application, r can be passed directly
    	// to an HTTP request, network connection, or another Writer.
    	written, err := io.Copy(io.Discard, r)
    	if err != nil {
    		fmt.Printf("failed to consume stream: %v\n", err)
    		return
    	}
    
    	fmt.Printf("Total bytes: %d\n", written)
    }

    Key Engineering Points:

    1. The header remains in memory, while the file content is read incrementally.
    2. io.MultiReader does not load the entire file into memory.
    3. The downstream consumer only needs to handle one io.Reader; it does not need to manually switch between data sources.
    4. io.MultiReader does not close file; the caller remains responsible for file.Close().
    5. If a non-EOF error occurs while reading the file, the error is returned through the combined Reader.
    6. In a real production scenario, the combined Reader can be passed directly to an HTTP request, network connection, or another Writer without first calling io.ReadAll.
    7. io.Copy processes the stream incrementally using its own internal buffer. That buffer belongs to io.Copy and is not memory accumulated by io.MultiReader.

    Common Failure Modes

    Failure: Assuming MultiReader Closes Underlying Resources

    ❌ Risky Code

    r := io.MultiReader(
    	resp.Body,
    	strings.NewReader("\n"),
    )
    
    data, err := io.ReadAll(r)
    if err != nil {
    	return err
    }
    
    _ = data

    Why It Fails

    io.MultiReader itself implements only io.Reader. It does not wrap or proxy the Close methods of underlying io.ReadCloser values, so the combined Reader cannot be used to close those resources.

    Reading the combined stream to EOF does not automatically close resp.Body.

    If the underlying Reader is an HTTP response body, file, network connection, or another closeable resource, the caller that owns the resource remains responsible for closing it.

    Otherwise, the application may encounter:

    • HTTP connections that cannot be reused efficiently.
    • Leaked file descriptors.
    • Network connections that remain open longer than necessary.
    • Other underlying resources that are not released promptly.

    Therefore:

    io.MultiReader composes reading behavior, not resource ownership.

    ✅ Correct Approach

    defer resp.Body.Close()
    
    r := io.MultiReader(
    	resp.Body,
    	strings.NewReader("\n"),
    )
    
    data, err := io.ReadAll(r)
    if err != nil {
    	return err
    }
    
    _ = data

    The ownership of resp.Body remains with the caller that received or created it.

    If the lifetimes of multiple underlying resources genuinely need to be bound to the lifetime of the combined stream, the application can define a small io.ReadCloser wrapper that explicitly owns and closes those resources.

    For example:

    type multiReadCloser struct {
    	io.Reader
    	closers []io.Closer
    }
    
    func (m *multiReadCloser) Close() error {
    	var firstErr error
    
    	for _, c := range m.closers {
    		if err := c.Close(); err != nil && firstErr == nil {
    			firstErr = err
    		}
    	}
    
    	return firstErr
    }

    This approach is useful when resource cleanup is intentionally part of the combined stream's ownership contract.

    However, this is an application-level resource-management strategy, not a capability provided by io.MultiReader itself.


    Failure: Assuming EOF Means Every Reader Was Successfully Consumed

    Consider:

    r := io.MultiReader(
    	firstReader,
    	secondReader,
    )

    If firstReader returns a non-EOF error:

    Reader 1 ──ERROR──▶ STOP
    
                         └── Reader 2 is never read

    MultiReader does not skip the failed Reader or continue with secondReader.

    Therefore, it is incorrect to assume:

    "MultiReader eventually consumes all Readers."

    The correct model is:

    Only when the preceding Reader reaches io.EOF can the next Reader be accessed.

    If the application requires retries, fallback Readers, or skipping failed data sources, that behavior must be implemented explicitly outside io.MultiReader.


    API Boundary Comparison

    RequirementAPICore GuaranteeMemory CharacteristicsTypical Scenario
    Sequentially combine multiple Readersio.MultiReaderReads the next Reader only after the current one reaches EOF; propagates non-EOF errorsStreaming; does not aggregate inputHeader + body, multiple file segments
    Limit the maximum number of bytes readio.LimitReaderThe returned Reader exposes at most N bytesStreaming; does not accumulate inputBound untrusted or oversized input
    Read exactly len(buf) bytesio.ReadFullerr == nil guarantees the buffer is completely filledUses caller-provided bufferFixed-length protocol headers
    Duplicate each write to multiple Writersio.MultiWriterSends each write to each Writer in order; stops and returns the first error encounteredDoes not accumulate the complete inputWrite logs to multiple destinations
    Copy read data to a Writer while readingio.TeeReaderData read from the source is also written to the WriterNo full-input internal bufferHashing, auditing, streaming copies
    Copy data from Reader to Writerio.CopyStreams data from one source to one destinationUses an internal buffer; does not accumulate the complete inputFile transfer, network proxying

    Key Takeaways

    io.MultiReader is a small but useful stream-composition primitive.

    Its core contract is simple:

    Treat multiple Readers as one sequential Reader.

    The important production rules are:

    • Readers are consumed strictly in the order provided.
    • The next Reader is accessed only after the current Reader returns io.EOF.
    • An intermediate io.EOF is treated as a transition to the next Reader.
    • A non-EOF error stops further progress and is returned to the caller.
    • It does not read multiple sources concurrently.
    • It does not aggregate all input into a single in-memory buffer.
    • It does not manage the lifetime of underlying resources.
    • If resource ownership must be bundled, an application-level io.ReadCloser wrapper can be introduced explicitly.

    The most important engineering distinction is:

    io.MultiReader only composes the sequential flow of data. It does not manage concurrency, error recovery, or underlying resource lifetimes.

    Use it when multiple data sources logically form one sequential stream.

    Do not use it as a concurrency primitive, resource manager, or retry mechanism.