The problem: child processes talk to the terminal
You are building a CLI tool that wraps a dependency. Maybe you run a linter and need to parse warnings. Maybe you execute a database migration and must log the result. The child process defaults to writing its output to the parent's standard streams. If you just run the command, the text floods the terminal and vanishes. You need to intercept the data, inspect it, or save it.
Go solves this by treating output streams as writable destinations. You create the command, attach a writer to capture the bytes, and run the process. The child writes to your writer instead of the terminal.
How Go captures streams
exec.Command creates a Cmd struct that represents the external process. The struct has fields like Stdout and Stderr. These fields accept any value that implements the io.Writer interface. The interface requires a single method: Write(p []byte) (n int, err error).
bytes.Buffer implements io.Writer. When you assign a buffer to cmd.Stdout, Go sets up a pipe between the child process and the buffer. As the child writes bytes, Go copies them into the buffer. When the command finishes, the buffer holds everything.
The pattern is consistent. Anything that can accept bytes can capture output. You can use a buffer, a file, a network connection, or a custom struct. The compiler enforces the type contract. If you try to assign a string to cmd.Stdout, the compiler rejects the code with cannot use "output" (untyped string constant) as io.Writer value in assignment.
Minimal example
Here's the baseline: spawn a command, attach buffers, wait for it to finish, and read the result.
package main
import (
"bytes"
"context"
"fmt"
"os/exec"
)
func main() {
// CommandContext creates a process that respects cancellation and deadlines
ctx := context.Background()
cmd := exec.CommandContext(ctx, "ls", "-l")
// bytes.Buffer implements io.Writer, which cmd.Stdout expects
var out bytes.Buffer
var errBuf bytes.Buffer
// Redirect streams to buffers; the command writes here instead of the terminal
cmd.Stdout = &out
cmd.Stderr = &errBuf
// Run executes the command and blocks until it finishes
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
// String() converts the buffer content to a readable string
fmt.Println("stdout:", out.String())
fmt.Println("stderr:", errBuf.String())
}
Walkthrough: pipes, buffers, and io.Writer
The code sets up the environment, redirects streams, and waits. exec.CommandContext is the entry point. It takes a context.Context as the first argument. This is a Go convention: context always goes first, named ctx. The context lets you cancel the command or set a timeout. If the context expires, the process gets killed.
bytes.Buffer starts empty. You take the address with & because cmd.Stdout expects a pointer to a writer. The buffer grows dynamically as data arrives. cmd.Run() starts the process and waits for it to exit. It returns an error if the process fails to start or exits with a non-zero status.
The io.Writer interface is the key abstraction. Go doesn't care what the writer does. It just calls Write with chunks of data. The buffer stores the chunks. A file appends them to disk. A network connection sends them over the wire. This flexibility lets you swap destinations without changing the command logic.
Convention aside: if err != nil { return err } is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally ignore an error. The compiler forces you to acknowledge it.
Buffers grow in memory. Stream when the output is unbounded.
Realistic pattern: fan-out with MultiWriter
Sometimes you need to see the output live while also saving it. A build tool might print progress to the terminal and write a log file. io.MultiWriter fans the stream to multiple writers simultaneously. Every write goes to all attached destinations.
Here's how to capture output while still displaying it.
package main
import (
"bytes"
"io"
"os"
"os/exec"
)
func runAndCapture() error {
// MultiWriter sends every write to all attached writers simultaneously
var logBuf bytes.Buffer
cmd := exec.Command("echo", "building...")
cmd.Stdout = io.MultiWriter(os.Stdout, &logBuf)
// Run the command; output appears on screen and fills the buffer
if err := cmd.Run(); err != nil {
return err
}
// The buffer holds a copy of everything printed
fmt.Println("captured:", logBuf.String())
return nil
}
io.MultiWriter creates a wrapper that implements io.Writer. When the command writes, the wrapper calls Write on os.Stdout and &logBuf. The terminal shows the text. The buffer keeps a copy. You can add more writers to the list. The order matters for error reporting, but all writers receive the data.
This pattern scales. You can fan out to a buffer, a file, and a metrics counter. The command code stays simple. The complexity lives in the writer composition.
Context is the kill switch. Always use CommandContext.
Pitfalls: memory, errors, and exit codes
Capturing output introduces specific failure modes. The most common is memory exhaustion. bytes.Buffer allocates a slice that grows as data arrives. If the command outputs gigabytes, the buffer consumes gigabytes of RAM. The process crashes with an out-of-memory error. Use streaming or a size limit if the output is unbounded.
Error handling requires care. cmd.Run() returns an error, but the error type tells you what happened. If the command exits with a non-zero status, the error is an *exec.ExitError. You can extract the exit code from it. If the command fails to start, the error is a different type.
// Extract the exit code from the error when the command fails
if exitErr, ok := err.(*exec.ExitError); ok {
code := exitErr.ExitCode()
fmt.Println("exit code:", code)
}
The type assertion checks if the error is an ExitError. If it is, you get the code. If not, the command failed for another reason, like a missing binary. The compiler rejects the program with loop variable i captured by func literal if you misuse loop variables in goroutines, but here the risk is runtime logic. Always check the error type when you need the exit code.
Helper functions like cmd.Output() and cmd.CombinedOutput() simplify common cases. Output() captures stdout and returns it. It returns an error if the command fails. CombinedOutput() merges stdout and stderr into a single buffer. These functions create the buffers for you. They are convenient but less flexible. You cannot fan out or inspect streams separately.
Convention aside: gofmt is mandatory. Don't argue about indentation or brace placement. Let the tool decide. Most editors run gofmt on save. The code above follows standard formatting.
The compiler forces error handling. Trust the boilerplate.
Decision: when to use this vs alternatives
Choose the right tool based on your needs. The standard library offers several options. Each has a specific use case.
Use exec.Command with bytes.Buffer when you need separate stdout and stderr streams. This gives you full control over each stream. You can capture them independently, fan them out, or discard one.
Use cmd.Output() when you only need stdout and expect the command to succeed. This is the simplest way to get output. It returns the bytes and an error. It does not capture stderr.
Use cmd.CombinedOutput() when you want a single stream and do not need to distinguish between output and errors. This merges both streams into one buffer. It is useful for logging or simple scripts where separation doesn't matter.
Use cmd.Start() with manual pipe handling when you need to process output incrementally as it arrives. This avoids buffering everything in memory. You read from the pipe in a goroutine and handle chunks as they come.
Use exec.CommandContext for every command to ensure you can cancel long-running processes. This prevents goroutine leaks and hanging processes. The context propagates cancellation to the child.
Goroutines are cheap. Channels are not magic.