Programs often need to present several pieces of data as one input stream.

A request body may need a generated header followed by a file. A test may need a prefix, a fixture, and a suffix. A protocol adapter may need to expose several existing readers through an API that accepts only one io.Reader.

The obvious solution is to read every piece into memory, concatenate the byte slices, and create a new reader over the result. That is reasonable for small, already-buffered data. It is a poor fit when one source is large, slow, or naturally streaming because the consumer cannot start until the combined buffer has been built.

Go’s io.MultiReader solves the narrower problem of sequential composition. It returns one io.Reader that reads the supplied readers in order. When one input reaches EOF, reading continues from the next input. After every input reaches EOF, the combined reader returns EOF.

The key mental model is simple:

reader A -> EOF
              |
              v
reader B -> EOF      one logical stream
              |             |
              v             v
reader C -> EOF --> MultiReader --> consumer

MultiReader does not make independent streams concurrent. It makes several streams look like one ordered stream.

Start with the smallest useful example

Suppose three pieces of text must be consumed as one input:

package main

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

func main() {
    reader := io.MultiReader(
        strings.NewReader("header\n"),
        strings.NewReader("body\n"),
        strings.NewReader("trailer\n"),
    )

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

    fmt.Print(string(data))
}

The output is:

header
body
trailer

The consumer sees one reader. It does not need to know where one source ends and the next begins.

This is the main value of MultiReader: it moves sequencing into the io.Reader abstraction instead of forcing every consumer to understand a list of sources.

EOF is a boundary between readers

For a normal io.Reader, io.EOF means that reader has no more input.

For MultiReader, EOF from an input has an additional meaning: move to the next reader.

The standard-library contract is:

reader 1 returns EOF -> continue with reader 2
reader 2 returns EOF -> continue with reader 3
...
last reader returns EOF -> MultiReader returns EOF

That behavior makes empty inputs unsurprising.

For example:

reader := io.MultiReader(
    strings.NewReader(""),
    strings.NewReader("payload"),
)

The empty first reader immediately reaches EOF, so the logical stream is simply payload.

You usually do not need to remove empty readers before constructing a MultiReader.

The consumer controls how quickly sources advance

MultiReader is still an io.Reader. Data moves only when the downstream consumer calls Read directly or through another API such as io.Copy, bufio.Scanner, or a decoder.

Imagine these inputs:

reader A: ABC
reader B: DEF

A consumer that asks for two bytes at a time might observe:

Read -> AB
Read -> C
Read -> DE
Read -> F
Read -> EOF

A different reader implementation or buffer size may produce different chunk boundaries. Callers must not assume that each Read maps to one source or one logical record.

The guarantee is about byte order, not read-call boundaries:

logical bytes: ABCDEF

This is the normal rule for Go’s io.Reader interface. If the application needs record boundaries, encode those boundaries in the data or use a higher-level parser.

Use MultiReader when a consumer already accepts io.Reader

MultiReader is most useful at an interface boundary.

Suppose an upload function already accepts an io.Reader:

func upload(src io.Reader) error {
    // Stream src to a destination.
    return nil
}

Now the caller needs to prepend a small generated header before streaming a large file.

Without changing upload, the caller can compose the input:

header := strings.NewReader("format=v1\n")

file, err := os.Open("records.dat")
if err != nil {
    return err
}
defer file.Close()

src := io.MultiReader(header, file)

if err := upload(src); err != nil {
    return err
}

The header is consumed first, then the file.

The file does not need to be read into a []byte merely to prepend a few bytes. That preserves the streaming shape of the operation.

Avoid pre-concatenating large inputs just to satisfy one Reader

A common alternative is:

fileData, err := os.ReadFile("records.dat")
if err != nil {
    return err
}

combined := append([]byte("format=v1\n"), fileData...)
return upload(bytes.NewReader(combined))

This can be perfectly acceptable when the total data is intentionally small and already fits comfortably in memory.

For a large file, however, it changes the resource behavior of the program. The complete file must be loaded before upload can consume the first byte, and memory must hold the combined representation.

The streaming form:

src := io.MultiReader(
    strings.NewReader("format=v1\n"),
    file,
)

lets the downstream reader begin with the header and then pull file data as needed.

That does not mean MultiReader automatically makes an operation fast or memory-free. The underlying readers and the downstream consumer still determine buffering, latency, and memory use. The benefit is that composition itself does not require you to materialize one complete combined byte slice.

Non-EOF errors remain real errors

EOF is special because it marks a successful boundary between inputs.

Other errors are not silently treated as boundaries.

The io.MultiReader documentation states that when an input returns a non-nil error other than EOF, Read returns that error.

Suppose the logical input is:

prefix reader
    |
failing reader
    |
suffix reader

If the middle source fails while an io.ReadAll is consuming the combined stream, the operation stops with that error. The suffix is not part of that successful read operation.

A small custom reader demonstrates the behavior:

type failingReader struct{}

func (failingReader) Read(p []byte) (int, error) {
    n := copy(p, "partial")
    return n, errors.New("source failed")
}

Used with:

reader := io.MultiReader(
    strings.NewReader("before-"),
    failingReader{},
    strings.NewReader("-after"),
)

 data, err := io.ReadAll(reader)

data contains the bytes successfully delivered before the failure, including partial, and err reports source failed.

The general io.Reader rule still applies: when a read returns both n > 0 and an error, callers must process the returned bytes before handling the error.

Most high-level standard-library consumers, including io.ReadAll and io.Copy, already follow the io.Reader contract for you.

Do not use EOF to signal an unexpected truncated structure

Because MultiReader interprets EOF as successful exhaustion of one source, the meaning of EOF in each component matters.

If an underlying reader represents a fixed-size structure and ends too early, plain EOF may be too weak a signal for that abstraction. The code responsible for validating the fixed-size structure should report an appropriate error such as io.ErrUnexpectedEOF when the input ends in the middle of required data.

For example, if a component must contribute exactly 16 bytes, enforce that requirement before treating the next reader as valid continuation:

var header [16]byte

if _, err := io.ReadFull(src, header[:]); err != nil {
    return err
}

Then compose validated pieces at the level that matches the protocol.

The principle is broader than MultiReader: EOF means graceful exhaustion. A structured format should not disguise malformed truncation as a normal boundary.

MultiReader does not insert separators

MultiReader concatenates bytes exactly as the component readers produce them.

These inputs:

io.MultiReader(
    strings.NewReader("alpha"),
    strings.NewReader("beta"),
)

form:

alphabeta

not:

alpha\nbeta

If the logical format requires separators, include them explicitly:

reader := io.MultiReader(
    strings.NewReader("alpha"),
    strings.NewReader("\n"),
    strings.NewReader("beta"),
    strings.NewReader("\n"),
)

For many small strings, building one string with strings.Builder may be simpler. MultiReader becomes more useful when at least one component is already a reader or should remain streaming.

Resource ownership stays with your code

io.MultiReader accepts io.Reader values. The io.Reader interface has only Read; it has no Close method.

That means MultiReader is not a resource manager for files, network responses, pipes, or other closable sources.

If you open a file before putting it into a MultiReader, you still own the file and must close it according to your program’s lifetime rules:

file, err := os.Open("records.dat")
if err != nil {
    return err
}
defer file.Close()

reader := io.MultiReader(
    strings.NewReader("header\n"),
    file,
)

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

The same principle applies to HTTP response bodies and other io.ReadCloser values.

Be especially careful when a combined read stops early because of cancellation or an error. Later readers may never be consumed, but any resources you opened for them still need cleanup.

Open expensive sources as late as their lifecycle requires

There is a practical consequence to resource ownership.

This code opens every file before the first byte is consumed:

first, err := os.Open("first.dat")
// handle err
second, err := os.Open("second.dat")
// handle err

reader := io.MultiReader(first, second)

If the consumer reads only a small prefix from first.dat and stops, second.dat was opened unnecessarily.

MultiReader itself takes existing readers; it is not a lazy factory for creating readers only when their turn arrives.

When opening a later source is expensive or must be delayed, a small purpose-built reader that opens the next source on demand may be a better abstraction. That design must also define who closes each source and what happens if opening a later source fails.

Do not force MultiReader into lifecycle management it was not designed to provide.

Bound each component when boundaries must be enforced

Sometimes a component reader can expose more bytes than the logical format permits.

Suppose a generated prefix must be followed by exactly 1 MiB from a source and then a trailer.

If you pass the source directly, it can consume all remaining bytes before the trailer is reached:

reader := io.MultiReader(prefix, source, trailer)

When the middle region has a fixed maximum size, wrap it with the appropriate boundary:

const bodySize = 1 << 20

reader := io.MultiReader(
    prefix,
    io.LimitReader(source, bodySize),
    trailer,
)

io.LimitReader returns EOF after its byte budget is exhausted, so MultiReader then advances to the trailer.

This combination is useful only when reaching that byte limit is the intended boundary. If the protocol requires exactly 1 MiB and the source may be shorter, LimitReader alone does not validate that exact length. A short underlying source still reaches EOF early.

Validate exact-length requirements separately when they matter.

Build multipart-like framing only when you own the format

MultiReader can make framing code look attractive:

reader := io.MultiReader(
    strings.NewReader("--boundary\r\n"),
    file,
    strings.NewReader("\r\n--boundary--\r\n"),
)

For a private format with simple, well-defined framing, this can be useful.

For standardized formats such as MIME multipart, however, handwritten framing is easy to get wrong. Standard formats have escaping, headers, delimiter rules, and edge cases beyond simple concatenation.

Prefer the standard library’s format-specific APIs when they exist. Use MultiReader to compose byte streams, not to replace a protocol implementation.

MultiReader is sequential, not parallel

The name can be misread as “read from many readers at once.” That is not what it does.

The conceptual flow is:

consume A completely
        |
        v
consume B completely
        |
        v
consume C completely

If reader A blocks waiting for data, MultiReader does not skip ahead to reader B.

That ordering is the feature: the result is deterministic sequential concatenation.

If the actual requirement is to merge events arriving concurrently from several sources, you need explicit concurrency and a rule for ordering. Channels, goroutines, queues, or application-specific fan-in logic may be appropriate depending on the data model.

Do not choose MultiReader when the sources are supposed to make progress independently.

Avoid concurrent reads unless the whole chain supports them

The io package documentation warns that clients should not assume I/O primitives are safe for parallel execution unless documented otherwise.

A MultiReader has sequential internal state: it must remember which source is current and which sources remain.

Treat the returned reader as a normal sequential io.Reader. If multiple goroutines need to consume one logical stream, coordinate access explicitly or redesign the ownership so one goroutine performs the reads and distributes parsed results.

A mutex around raw reads can serialize access, but it does not automatically make a byte stream meaningful to several independent consumers. Usually the more important question is which component owns parsing and record boundaries.

Use MultiReader for tests that need realistic stream boundaries

MultiReader is also useful in tests because it can simulate an input whose bytes arrive from separate readers without changing the code under test.

Suppose a parser should handle a record even when its bytes are split across source boundaries:

reader := io.MultiReader(
    strings.NewReader("user="),
    strings.NewReader("4812"),
    strings.NewReader("\n"),
)

A correct stream parser should see:

user=4812\n

It should not depend on the source boundaries.

This is a useful way to expose code that incorrectly assumes one Read call returns one complete logical token.

For even more aggressive tests, use a custom reader that deliberately returns very small chunks. MultiReader tests source transitions; a short-read reader tests read-call fragmentation.

Common mistakes

Assuming one reader equals one record

MultiReader preserves byte order, not application records. A downstream buffer may read across source boundaries, or an underlying reader may split its own data across many reads.

Define record framing in the data format.

Forgetting separators

The readers are concatenated exactly. Add delimiters explicitly when the format requires them.

Expecting MultiReader to close inputs

It receives io.Reader, not io.ReadCloser. Keep ownership and cleanup explicit.

Treating every error like EOF

Only EOF represents successful exhaustion of a component. Other errors must remain visible to the caller.

Using it for concurrent fan-in

MultiReader is ordered and sequential. It does not multiplex active sources.

Using it when a plain buffer is simpler

For three tiny, already-available strings, strings.Builder or direct concatenation may be clearer. Streaming abstractions are useful when the data or interfaces are actually stream-oriented.

When MultiReader is a good fit

Use io.MultiReader when all of these conditions are approximately true:

  • the consumer wants one io.Reader;
  • the sources must appear in a fixed order;
  • moving to the next source on normal EOF is the desired behavior;
  • at least one component is naturally stream-oriented or should not be pre-buffered;
  • resource cleanup can remain explicit outside the combined reader.

Choose a different approach when sources must be read concurrently, when later resources must be opened lazily, when the format needs complex protocol-aware framing, or when the complete data is already small and simpler to build directly.

Conclusion

io.MultiReader is a small adapter with a precise job: present several readers as one logical sequential stream.

Its most important boundary is EOF. Normal EOF advances to the next source; a non-EOF error remains an error. The abstraction preserves byte order but does not preserve source boundaries as records, insert separators, close resources, open later sources lazily, or read sources concurrently.

Use it when existing stream-oriented components need to be connected without first materializing one combined payload. Keep protocol framing, exact-length validation, concurrency, and resource lifetime at the layers that actually own those concerns.