How to Use io.MultiWriter in Go

Use io.MultiWriter to write identical data to multiple io.Writer destinations at the same time.

The fan-out adapter for Go's io package

You are building a command-line tool that processes data. The user wants to see progress on the terminal in real time. You also want to save that exact same output to a log file so you can debug failures later. You could write the data to the file, then write it again to the terminal. That duplicates the write logic and makes error handling messy. You could buffer everything in memory and write it out at the end, but that defeats the purpose of real-time progress and wastes RAM.

Go's standard library solves this with io.MultiWriter. It takes multiple io.Writer destinations and returns a single io.Writer. When you write to the returned writer, the data fans out to every destination simultaneously. It is the Go equivalent of a splitter cable for data streams.

The io.Writer interface

To understand MultiWriter, you need to understand the contract it operates on. Go's I/O is built around interfaces, not concrete types. The io.Writer interface defines a single method:

type Writer interface {
    Write(p []byte) (n int, err error)
}

Any type that implements this method is a valid destination for data. os.Stdout, os.Stderr, *os.File, bytes.Buffer, and even network connections all implement io.Writer. This uniformity is why MultiWriter works. It does not care if you are writing to a file, a socket, or a memory buffer. It only cares that the destination accepts a byte slice and returns a count and an error.

The convention in Go is to accept interfaces and return structs. Functions that need to write data should accept an io.Writer parameter, not a *os.File. This makes your code testable and flexible. You can pass os.Stdout in production and a bytes.Buffer in tests without changing the function signature.

Minimal example

Here is the simplest way to use io.MultiWriter. You create two writers, combine them, and write once.

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
)

func main() {
	// Buffer captures data in memory for verification
	var buf bytes.Buffer
	// Stdout sends data to the terminal
	// MultiWriter returns a single Writer that fans out to both
	w := io.MultiWriter(&buf, os.Stdout)

	// Write to the combined writer
	// The data appears on the terminal and lands in the buffer
	fmt.Fprintln(w, "Hello, multi-destination!")

	// Verify the buffer received the exact same bytes
	// This pattern is common in tests to capture output
	fmt.Printf("Buffer captured: %q\n", buf.String())
}

The fmt package functions like Fprintln accept any io.Writer. By passing the result of MultiWriter to fmt.Fprintln, you delegate the fan-out logic to the standard library. The buffer and stdout receive the data in a single call.

How it works under the hood

io.MultiWriter does not use magic. It creates a small struct that holds a slice of io.Writer values. When you call Write on the multi-writer, it iterates over the slice and calls Write on each destination.

The implementation tracks the total number of bytes written. It sums the n values returned by each destination. If a destination writes fewer bytes than requested, the multi-writer records that partial count. If a destination returns an error, the multi-writer stops immediately and returns that error. It does not continue writing to the remaining destinations.

This behavior has implications. If you have three writers and the second one fails, the first one has already received the data. The third one receives nothing. The caller sees an error and must decide how to handle the partial write. The multi-writer itself does not roll back or retry. It is a simple fan-out adapter, not a transaction manager.

Realistic example: Build logs

A common use case is a build system that logs to a file while printing to stderr. Stderr is the correct destination for progress and diagnostic messages in CLI tools, leaving stdout free for actual program output.

package main

import (
	"fmt"
	"io"
	"os"
)

// RunBuild simulates a build process that logs to a file and stderr
func RunBuild(logPath string) error {
	// Open the log file for writing
	// Truncate creates or clears the file
	f, err := os.Create(logPath)
	if err != nil {
		// Return the error immediately
		// The caller handles the failure
		return fmt.Errorf("create log file: %w", err)
	}
	// Ensure the file closes when the function returns
	// This prevents file descriptor leaks
	defer f.Close()

	// Combine the file and stderr
	// Writes go to both destinations
	mw := io.MultiWriter(f, os.Stderr)

	// Simulate build steps
	fmt.Fprintln(mw, "Starting build...")
	fmt.Fprintln(mw, "Compiling packages...")
	fmt.Fprintln(mw, "Linking binary...")

	// Return success
	return nil
}

func main() {
	if err := RunBuild("build.log"); err != nil {
		fmt.Fprintf(os.Stderr, "Build failed: %v\n", err)
		os.Exit(1)
	}
}

The RunBuild function accepts a path and handles the file setup internally. It uses defer f.Close() to guarantee cleanup. The MultiWriter combines the file and stderr. Every call to fmt.Fprintln with the multi-writer updates both the log file and the terminal. The user sees progress, and the log file captures the history.

Pitfalls and edge cases

io.MultiWriter is simple, but it has traps if you treat it like a black box. The two biggest issues are error semantics and buffering.

Error handling stops at the first failure

As mentioned, MultiWriter returns the first error it encounters. It does not aggregate errors. If you write to a network socket and a file, and the socket closes, the write returns an error. The file might have received the data, or it might not, depending on the order of the writers in the slice.

The compiler will not catch this logic error. You get a runtime error value. Your code must check the error and decide if the partial write is acceptable. In many logging scenarios, it is. If the log file fails, you might want to continue writing to stderr. MultiWriter cannot do that. It is all-or-nothing per write call.

If you need independent error handling, write to each destination separately.

// Separate writes allow independent error handling
n1, err1 := f.Write(data)
n2, err2 := os.Stderr.Write(data)
// Handle err1 and err2 individually

Buffering is your responsibility

MultiWriter writes raw bytes. It does not know about buffers. If you pass bufio.Writer instances to MultiWriter, the bytes sit in the buffer until the buffer fills up or you call Flush. MultiWriter does not call Flush on the underlying writers.

If you wrap a file in a buffered writer and combine it with stdout, the file might not receive data until the buffer flushes. If your program crashes before the flush, the data is lost. Always flush buffered writers explicitly, or use unbuffered writers for critical output.

// BAD: Buffered writer inside MultiWriter without flushing
bufWriter := bufio.NewWriter(f)
mw := io.MultiWriter(bufWriter, os.Stderr)
fmt.Fprintln(mw, "Data")
// Data might still be in bufWriter's buffer, not on disk

// GOOD: Flush explicitly
bufWriter.Flush()

The convention in Go is to keep buffering close to the I/O boundary. Wrap the file in a buffered writer, use that for writes, and flush at the end. Do not mix buffered and unbuffered writers in a MultiWriter unless you control the flush cycle.

Performance considerations

MultiWriter copies data. Every byte you write is sent to every destination. If you have a high-throughput stream and three destinations, you are tripling the memory bandwidth usage. This is usually fine for logs and text output. It is not suitable for streaming large binary files where performance is critical.

If you need to copy a large file to multiple destinations, consider using io.Copy with a custom reader or writer that handles the fan-out more efficiently. Or just write sequentially if the destinations can handle it. MultiWriter is a convenience adapter, not a high-performance streaming engine.

Decision matrix

Choose the right tool based on your error handling and performance needs.

Use io.MultiWriter when you need to fan out writes to multiple io.Writer destinations with minimal code and can tolerate all-or-nothing error semantics per write call.

Use separate write calls when you need independent error handling for each destination or must handle partial writes differently for each sink.

Use io.TeeReader when you need to fan out reads from a single io.Reader source to multiple consumers.

Use a custom struct implementing io.Writer when you need to transform data for specific destinations before writing, such as adding timestamps to one log but not another.

Use plain sequential writes when performance is critical and you cannot afford the overhead of copying data to multiple destinations in a single call.

Where to go next

io.MultiWriter is a fan-out adapter, not a transaction manager. Handle errors explicitly and flush buffers yourself.