🧑🍳 Analogy
Sometimes your program is the head chef who doesn’t cook every dish personally — it delegates to specialist line cooks (git, ffmpeg, pg_dump) and collects their results. os/exec is the kitchen pass: you write the ticket (the command and its arguments), decide how the dish comes back (captured all at once, or streamed plate by plate), set a timer so a slow cook gets pulled, and clean up when service ends. Crucially, you hand the cook an argument list, not a sentence to interpret — so there’s no chance a weird order becomes a destructive instruction.
Running an external program
Go shells out through os/exec. The simplest forms capture output:
// Output(): run to completion, return stdout as []byte.
out, err := exec.Command("git", "rev-parse", "HEAD").Output()
if err != nil {
// *exec.ExitError carries the non-zero exit + stderr
log.Fatalf("git failed: %v", err)
}
fmt.Printf("commit: %s", out)
// CombinedOutput(): stdout + stderr together (handy for diagnostics).
out, _ = exec.Command("go", "version").CombinedOutput()
The three ways to launch, by how much control you want:
graph TD C["exec.Command(name, args...)"] --> R["Run() = Start + Wait<br/>(block until done)"] C --> S["Start()<br/>(returns now; Wait() later)"] C --> O["Output()/CombinedOutput()<br/>(run, return captured bytes)"]
Wiring streams, environment, and working dir
A *exec.Cmd is a struct you configure before launching — redirect its streams to any io.Writer/io.Reader, set its environment and directory:
cmd := exec.Command("my-tool", "--flag", userInput) // args are safe, see below
cmd.Stdin = strings.NewReader("data on stdin")
cmd.Stdout = os.Stdout // inherit our stdout
cmd.Stderr = os.Stderr
cmd.Dir = "/work" // run in this directory
cmd.Env = append(os.Environ(), "LANG=C") // tweak the child's environment
if err := cmd.Run(); err != nil { /* ... */ }
fmt.Println("child pid was:", cmd.ProcessState.Pid())
For long-running commands, stream instead of buffering:
cmd := exec.Command("tail", "-f", "app.log")
stdout, _ := cmd.StdoutPipe()
cmd.Start()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() { // process each line as it arrives
fmt.Println("log:", scanner.Text())
}
cmd.Wait()
Timeouts and cancellation
Never run an external command without a leash. CommandContext kills the child when the context is cancelled or times out:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "slow-converter", "in.mov", "out.mp4")
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
log.Println("converter timed out and was killed")
}
}
Inspecting the current process
You can’t spawn programs on the playground, but you can introspect the running process — its PID, parent, environment, and arguments. This runs anywhere:
package main
import (
"fmt"
"os"
"strings"
)
func main() {
fmt.Println("pid: ", os.Getpid())
fmt.Println("parent pid: ", os.Getppid())
fmt.Println("args: ", os.Args)
// The environment is inherited from the parent and handed to children.
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "PATH=") {
fmt.Println("PATH is set, length:", len(kv))
break
}
}
// os.Executable() is the path to this running binary.
if exe, err := os.Executable(); err == nil {
fmt.Println("executable: ", exe)
}
}
When you Start a child, it inherits this process’s environment by default; set cmd.Env to change that. The child’s PID lives in cmd.Process.Pid once started.
Under the hood: fork + exec
exec.Command(...).Start() is two classic syscalls in a trench coat:
graph LR P["parent process"] -->|"fork(2)"| C["child: a clone of the parent"] C -->|"execve(2)"| N["child's memory replaced<br/>by the new program"] P -->|"wait4(2)"| R["reap exit status<br/>(ProcessState)"]
forkduplicates the calling process, giving a child with copies of its fds and memory (copy-on-write).execvethen replaces that child’s program image with the new binary — same PID, brand-new code.waitlets the parent collect the child’s exit status. Until it does, a finished child is a zombie holding a slot in the process table.
Go hides all three: Start does the fork+exec, and Wait (or Run/Output, which call it) does the reaping and fills in cmd.ProcessState:
cmd := exec.Command("false") // exits non-zero
err := cmd.Run()
var ee *exec.ExitError
if errors.As(err, &ee) {
fmt.Println("exit code:", ee.ExitCode()) // 1
fmt.Println("user CPU time:", ee.UserTime()) // from ProcessState
}
So “always Wait()” isn’t style advice — it’s how you reap the zombie. And the child inheriting fds is exactly what makes pipes between parent and child work: the parent’s os.Pipe write end survives the fork into the child.
🐹 No shell means no shell-injection — by default
The single most important safety property: exec.Command(name, args...) runs the binary directly with an argv array — there’s no shell parsing, so a user-supplied argument like ; rm -rf / is just a harmless literal string passed to the program. The classic injection hole only opens if you choose to run exec.Command("sh", "-c", "tool "+userInput). Avoid sh -c with interpolated input; pass arguments separately and you’re safe. (See the broader treatment on the stdlib os/exec page.)
⚠️ Zombies, deadlocks, and lookups
Three classics. Always Wait() (or use Run) — a Started child you never wait on becomes a zombie that lingers in the process table. Don’t deadlock on pipes — if you set StdoutPipe you must read it before Wait(), and a child that fills a pipe buffer while you’re blocked elsewhere hangs both sides (using cmd.Output() avoids this). And exec.Command doesn’t error if the binary is missing until you Run — check the error, which will be exec.ErrNotFound if it’s not on PATH.
See also
- signals — sending/handling signals to control processes.
- pipes — wiring one process’s output into another’s input.
- os/exec (stdlib) — the package in depth, including security.
- context & cancellation (patterns) — the leash for
CommandContext.
Next: how a process reacts to the outside world — signals.
Related topics
Reacting to OS signals in Go — os/signal.Notify, catching SIGINT/SIGTERM for graceful shutdown, signal.NotifyContext, and ignoring or resetting signals.
ipcPipesThe simplest IPC — anonymous pipes (os.Pipe, io.Pipe), named pipes (FIFOs), piping one process's output into another, and back-pressure.
Check your understanding
Score: 0 / 51. What's the difference between cmd.Run(), cmd.Start(), and cmd.Output()?
Run = Start + Wait (blocks until done). Start launches the process and returns right away, so you can do other work and call Wait() when ready. Output runs to completion and returns the child's stdout as []byte (CombinedOutput adds stderr).
2. Why should you prefer exec.CommandContext over exec.Command?
exec.CommandContext(ctx, ...) kills the process when ctx is cancelled or its deadline passes — essential for not leaking runaway children. Pair it with context.WithTimeout to bound how long an external command may run.
3. How do you safely pass user input as an argument to a command?
exec.Command does NOT invoke a shell — it execs the program with an argv array, so metacharacters in userInput are just literal argument text, not commands. The injection risk only appears if you deliberately run sh -c "..."+userInput. Pass args separately and you're safe by default.
4. How do you capture a child process's output as a stream rather than all at once?
StdoutPipe()/StderrPipe() return readers you consume while the process runs (good for long-running commands or huge output). Output()/CombinedOutput() buffer everything and return it after exit (simpler for short commands). You can also set cmd.Stdout to any io.Writer to redirect directly.
5. On the Go Playground, can you run external programs with os/exec?
The playground sandbox blocks process creation and has no /bin, so os/exec calls fail there. The code on this page is correct for a real machine; we show process/env introspection runnably and keep the exec examples in fenced blocks.
Comments
Sign in with GitHub to join the discussion.