skip to content

In os/exec, when should you use cmd.StdoutPipe() instead of assigning a bytes.Buffer to cmd.Stdout?

level: juniorimportance: should knowfreq 40%

answer

  1. two ways to capture a child's output
  2. one of them makes you the drainer
  3. os/exec can run the copy goroutine for you
  4. streaming versus holding it all in memory
  5. the pipe adds an ordering rule

basics

~10 s

Use cmd.StdoutPipe when you must stream output as it arrives, or when the output is too large to hold in memory. Assigning a bytes.Buffer to cmd.Stdout is simpler: os/exec drains the child for you.

solid answer

~50 s

Both work, and the buffer form is the default choice. When you set `cmd.Stdout` to any writer that is not an `*os.File`, os/exec creates the OS pipe itself, starts a goroutine copying the child's output into your writer, and `cmd.Wait` waits for that copy to finish — so there is nothing for you to drain and no ordering rule to get wrong. `cmd.StdoutPipe()` hands you the read end instead, and with it the obligations: you must read it to end of file before calling `cmd.Wait`, and if the child also produces a lot of output on another stream you must drain that concurrently or the child blocks. Reach for the pipe when the output is unbounded or you want to act on lines as they appear; keep the `bytes.Buffer` when the output is small and you only need it once the command has finished.

code

go · 11 lines
go
cmd := exec.Command("wc", "-l")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Start(); err != nil {
	return err
}
if err := cmd.Wait(); err != nil {
	return err
}
// out now holds the complete output
fmt.Print(out.String())

go deeper

for a junior

Know that there are two ways to capture a child's output and be able to name them: assign a writer such as a bytes.Buffer to cmd.Stdout, or take the reader returned by cmd.StdoutPipe.

for a middle

Explain what os/exec does in each case: a non-file writer gets an internal pipe plus a copying goroutine that cmd.Wait joins, while cmd.StdoutPipe hands you the read end and the obligation to drain it.

for a senior

Show that you choose by output volume and lifetime, streaming for unbounded or long-lived children and buffering only for output you know is small, and that you can say what an unbounded buffer does to the parent's memory.

for a principal

Be ready to argue for one house shape in the team's subprocess helper so no call site can get the draining rule wrong, and to say what that helper caps, times out and reports.

## Two ways to get a child's output `os/exec` gives you two quite different mechanisms for capturing what a child process writes to standard output, and choosing between them is the first decision you make when you shell out from Go. **Shape one — give os/exec a writer.** ```go cmd := exec.Command("wc", "-l") var out bytes.Buffer cmd.Stdout = &out ``` `cmd.Stdout` is an `io.Writer`. What os/exec does with it depends on what you put there: - **`nil`** — the child's standard output is connected to the null device (`os.DevNull`). Output is discarded; it is not inherited from the parent. - **an `*os.File`** — that file's descriptor is handed straight to the child. No pipe is created, nothing is copied inside your process, and the child writes to the file or terminal itself. This is the cheapest form. - **any other writer** — os/exec creates an operating-system pipe, gives the write end to the child, and starts an internal goroutine that copies from the read end into your writer. `cmd.Wait` waits for the process *and* for that copying goroutine before it returns. That last point is the whole convenience: the draining happens whether or not you think about it, so the child can never block on a full pipe because of your code, and by the time `cmd.Wait` returns the writer holds everything. **Shape two — take the pipe yourself.** ```go stdout, err := cmd.StdoutPipe() ``` `cmd.StdoutPipe()` returns an `io.ReadCloser` — the parent's read end of a pipe whose write end goes to the child. Now you are the drainer, and you inherit three obligations: 1. **Read it to end of file before calling `cmd.Wait`.** `Wait` closes the pipes it created once the child exits, so a read that has not finished loses whatever was still in flight and then fails against a closed file. 2. **Keep reading while the child runs.** A pipe is a small fixed-size kernel buffer, not a queue that grows. If you stop reading, the child blocks on its next write. 3. **Drain every pipe you took, concurrently.** If you take both `cmd.StdoutPipe()` and `cmd.StderrPipe()` and read them one after the other, the unread one can fill and stall the child. You do not normally need to `Close` the reader: `Wait` closes it for you. ## How to choose Ask two questions about the child's output. **How much of it is there?** A `bytes.Buffer` grows without bound. Every byte the child prints is retained in your process's memory until `cmd.Wait` returns. For a command whose output you can bound — a version string, a few thousand lines of a report, a checksum — that is fine and simplest. For a child that streams a converted dataset, tails a log, or is not fully under your control, buffering it all is an out-of-memory incident waiting for a big input. **When do you need it?** A buffer is only useful *after* the command finishes. If you want to show progress, parse records as they appear, forward them to another sink, or stop early when you have seen enough, you need the pipe, because that is the only shape that lets you act on the first bytes while the child is still running. ```go stdout, _ := cmd.StdoutPipe() cmd.Start() sc := bufio.NewScanner(stdout) for sc.Scan() { fmt.Println(sc.Text()) } cmd.Wait() ``` ## A middle path The two shapes are not exclusive. `cmd.Stdout` accepts any writer, so you can pass something that is not a `bytes.Buffer`: an open file, a `bufio.Writer` around a network connection, an `io.MultiWriter`, or your own small writer that counts bytes and returns an error past a cap. You keep os/exec's automatic draining and still avoid unbounded memory. Likewise, if you have taken a pipe and simply want all of it, `io.ReadAll(stdout)` before `cmd.Wait` is correct — but at that point you have written by hand what setting `cmd.Stdout` would have done for you. ## The practical rule Default to assigning a writer, because it removes an entire class of ordering and draining bugs. Move to `cmd.StdoutPipe()` deliberately, when streaming or unbounded volume makes it necessary — and when you do, treat "read to end of file, then `cmd.Wait`" as a single indivisible sequence.

  • What does os/exec do differently when cmd.Stdout is already an *os.File?
    It passes that file's descriptor straight to the child, so there is no pipe, no copying goroutine and no extra buffering — the child writes to the file or terminal itself. Any other writer implies an internal pipe plus a copy that cmd.Wait must join before it returns.
  • Why is a bytes.Buffer on cmd.Stdout risky for a long-running or untrusted child?
    It grows without bound: every byte the child prints stays in your process's memory until cmd.Wait returns, so a chatty or hostile child can drive the parent into an out-of-memory kill. When the volume is unknown, stream with cmd.StdoutPipe, or assign a writer that stops after a cap you chose, for example one wrapping io.CopyN.
  • If cmd.Stdout is nil, does the child inherit the parent's standard output?
    No. A nil cmd.Stdout connects the child's standard output to the null device, so the output is discarded rather than appearing in your terminal. Inheriting is something you ask for explicitly by assigning the parent's own *os.File to the field.

saying these in an interview costs you the question

  • Thinks you must drain a bytes.Buffer assigned to cmd.Stdout
  • Believes cmd.StdoutPipe buffers unlimited output for you
  • Assumes a buffered output is complete before cmd.Wait returns
  • Picks a bytes.Buffer for output of unknown size
  • Says a nil cmd.Stdout makes the child share the parent's terminal