05 - Running Processes
📋 Jump to Takeaways🎁 Your program needs to shell out to git, resize an image with ffmpeg, or run a backup script. How does Go start another program, feed it input, capture what it prints, and find out whether it actually worked?
Starting other programs is bread-and-butter systems work. Go's os/exec package wraps the low-level fork/exec dance in a clean API. This lesson covers running commands, wiring their input and output, reading exit codes, enforcing timeouts, and chaining commands together.
Running a Command
exec.Command builds a command; the program name and each argument are separate strings (no shell, so no quoting headaches). Then you pick how to run it:
// .Output() runs the command and returns its stdout
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
if err != nil {
log.Fatal(err)
}
fmt.Printf("commit: %s", out) // commit: 2e77287.Run() runs and waits but discards output, .Output() captures stdout, and .CombinedOutput() captures stdout and stderr together. Each argument is passed literally, so exec.Command("ls", "*.go") looks for a file named *.go. There is no glob expansion, because there is no shell involved.
Wiring Input and Output
For anything beyond a quick capture, set the command's streams directly. They are plain io.Reader/io.Writer fields, so you can point them anywhere:
cmd := exec.Command("wc", "-l")
cmd.Stdin = strings.NewReader("one\ntwo\nthree\n")
cmd.Stdout = os.Stdout // child writes straight to our stdout
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
// 3When you need to stream rather than buffer, use cmd.StdoutPipe() or cmd.StdinPipe() with cmd.Start() and cmd.Wait(). Start launches the process without blocking, you read or write through the pipe, then Wait reaps it. Reaping means the parent collects the finished child's exit status, and skipping it leaves the dead child as a zombie in the process table:
cmd := exec.Command("ping", "-c", "3", "example.com")
pipe, _ := cmd.StdoutPipe()
cmd.Start()
scanner := bufio.NewScanner(pipe)
for scanner.Scan() {
fmt.Println("ping:", scanner.Text())
}
cmd.Wait()Environment and Working Directory
By default a child inherits the parent's environment and current directory. Override them with cmd.Env and cmd.Dir. When you set cmd.Env, it replaces the environment entirely, so start from os.Environ() if you want to add rather than reset:
cmd := exec.Command("printenv", "STAGE")
cmd.Env = append(os.Environ(), "STAGE=production")
cmd.Dir = "/srv/app"
out, _ := cmd.Output()
fmt.Print(string(out)) // productionLeaving cmd.Env as nil (the zero value) means "inherit everything," which is what you want most of the time.
Reading Exit Codes
Every program returns a small integer when it exits, where 0 means success and any non-zero value signals a failure. A non-zero exit status is reported as an *exec.ExitError. Use errors.As (the pattern from The OS Interface) to pull it out and read the numeric code:
err := exec.Command("false").Run() // the `false` command always exits 1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
fmt.Println("exit code:", exitErr.ExitCode()) // exit code: 1
}A nil error means the command exited 0. Any other error type (for example, the program not being found on PATH) means the command never ran to completion at all, which is a different failure from "ran and returned non-zero."
Timeouts and Cancellation
A command that hangs shouldn't hang your program. exec.CommandContext ties the process to a context.Context: when the context is cancelled or its deadline passes, Go kills the process.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "sleep", "10").Run()
fmt.Println(err) // signal: killed
fmt.Println(errors.Is(ctx.Err(), context.DeadlineExceeded)) // trueThis is the same context you use throughout concurrent Go; if you want a refresher on deadlines and cancellation, see Context Deep Dive. Always prefer the context form for anything that talks to the outside world.
Command Pipelines
To reproduce a shell pipeline like ps aux | grep go, connect the first command's StdoutPipe to the second command's Stdin, start both, and wait on both:
ps := exec.Command("ps", "aux")
grep := exec.Command("grep", "go")
grep.Stdin, _ = ps.StdoutPipe()
grep.Stdout = os.Stdout
grep.Start()
ps.Run() // run the producer
grep.Wait() // then wait for the consumer to finishNotice there is still no shell here, which is a feature. If you genuinely need shell behavior (globs, |, &&), you can run exec.Command("sh", "-c", line), but never build that line from untrusted input: it's a command-injection hole. Prefer wiring pipes yourself.
The fork/exec Model
Under os/exec sits the primitive os.StartProcess, which performs the classic Unix two-step: fork clones the current process, then exec replaces the clone's image with the new program. exec.Cmd handles all of that plumbing (descriptor setup, argument marshalling, reaping) for you, so you should reach for exec.Command rather than os.StartProcess in ordinary code.
Key Takeaways
exec.Command(name, args...)builds a command with each argument passed literally (no shell, no glob expansion). Run it with.Run(),.Output()(stdout), or.CombinedOutput()(stdout+stderr).- Set
cmd.Stdin/cmd.Stdout/cmd.Stderrto anyio.Reader/io.Writer; useStdoutPipe/StdinPipewithStart()+Wait()to stream. cmd.Env(nil inherits; setting it replaces, so useappend(os.Environ(), ...)) andcmd.Dircontrol the child's environment and directory.- A non-zero exit yields an
*exec.ExitError;errors.Asit and call.ExitCode(). Anilerror means exit 0; other error types mean the command never ran. exec.CommandContext(ctx, ...)kills the process on context cancel or timeout. Prefer it for anything external.- Build pipelines by connecting one command's
StdoutPipeto another'sStdin. Avoidsh -cwith untrusted input (injection risk);exec.Cmdwraps the low-levelos.StartProcessfork/exec.
🎁 You can start and stop processes now. But the outside world also talks to your program: press Ctrl+C and it dies, and systemd sends a polite "please stop" before it pulls the plug. How does your program catch those messages and shut down cleanly instead of dropping everything mid-flight?