{} The Go Reference

System · Stdlib · Intermediate

os/exec

Running external programs from Go — exec.Command, Run vs Output vs Start, capturing stdout/stderr, streaming and pipes, context timeouts, environment and working directory, and why there's no shell (and why that's a security win).

System Intermediate ⏱ 6 min read Complete

🔧 Analogy

os/exec lets your program hire another program to do a job — git, ffmpeg, psql — and collect the result. Unlike typing into a shell, you hand the worker its instructions as a list of separate slips (the arguments), not one sentence it has to parse. That difference is the whole reason there’s no command-injection hole: user input is always cargo, never instructions.

🧪 These examples are shown as code, not the live runner

The go.dev sandbox can’t launch external programs, so the snippets below are fenced (not runnable) — copy them into a real module to try. Everything else on this page is standard, current Go.

Running a command

You build a *exec.Cmd with exec.Command(name, args...) and then choose how to run it. The three common entry points trade convenience for control:

import "os/exec"

// 1. Run and wait — error only. You must wire Stdout yourself to see output.
cmd := exec.Command("git", "status", "--porcelain")
err := cmd.Run()

// 2. Output — run, wait, and return stdout as []byte (the usual choice).
out, err := exec.Command("git", "rev-parse", "HEAD").Output()
fmt.Printf("commit: %s", out)

// 3. CombinedOutput — stdout and stderr merged into one []byte.
out, err = exec.Command("go", "vet", "./...").CombinedOutput()

Each argument is separateexec.Command("ls", "-l", "/tmp"), never exec.Command("ls -l /tmp"). There is no shell to split the string, so spaces in a filename “just work” and hostile input can’t smuggle in ; rm -rf /.

graph LR
C["exec.Command(name, args...)"] --> CMD["*exec.Cmd"]
CMD -->|"Run()"| W["wait → error"]
CMD -->|"Output()"| O["stdout []byte + error"]
CMD -->|"Start() + Wait()"| A["async: run while you do other work"]

Capturing output and exit codes

Output() returns stdout; when the program exits non-zero, the error is a concrete *exec.ExitError carrying the exit code (and, with Output(), the captured stderr):

out, err := exec.Command("go", "build", "./...").Output()
if err != nil {
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		fmt.Printf("build failed (exit %d):\n%s", ee.ExitCode(), ee.Stderr)
	} else {
		// e.g. the program wasn't found on PATH
		fmt.Println("could not run:", err)
	}
	return
}
fmt.Printf("ok: %s", out)

A nil error means the process exited 0. Distinguish “ran and failed” (*exec.ExitError) from “couldn’t start” (a LookPath/permission error) — they need different handling.

Streaming, pipes, and stdin

For long output, don’t buffer it all — wire the child’s streams to yours (or to a file). For full control, attach pipes:

// Stream child output straight to this process's stdout/stderr.
cmd := exec.Command("make", "build")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()

// Feed stdin from a string.
cmd = exec.Command("grep", "needle")
cmd.Stdin = strings.NewReader("hay\nneedle\nstack\n")
out, _ := cmd.Output() // "needle\n"

// A pipe lets you read incrementally while the command runs.
cmd = exec.Command("tail", "-f", "app.log")
stdout, _ := cmd.StdoutPipe()
cmd.Start()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
	fmt.Println("line:", scanner.Text())
}
cmd.Wait()

Start() launches without waiting; pair it with Wait() (after you’ve drained the pipes) to reap the process. cmd.Stdout/Stdin/Stderr accept any io.Writer/io.Reader, so a bytes.Buffer, a file, or a network connection all work.

Timeouts, environment, and working directory

A child process should almost always have a deadline. exec.CommandContext kills it when the context is cancelled. You can also set its environment and working directory:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "slow-tool", "--big")
cmd.Dir = "/srv/data"                          // working directory
cmd.Env = append(os.Environ(), "LANG=C")        // inherit + override
if err := cmd.Run(); err != nil {
	if ctx.Err() == context.DeadlineExceeded {
		fmt.Println("timed out, process killed")
	}
}

cmd.Env = nil (the default) inherits the parent’s environment; setting it replaces the whole environment, so build on os.Environ() when you only mean to add a variable.

Reference

GoalCall
Build a commandexec.Command(name, args...)
Run, wait, error onlycmd.Run()
Capture stdoutcmd.Output()[]byte
Capture stdout+stderrcmd.CombinedOutput()
Run asynchronouslycmd.Start() then cmd.Wait()
Stream output liveset cmd.Stdout/cmd.Stderr
Read output incrementallycmd.StdoutPipe()
Feed inputset cmd.Stdin
Add a timeoutexec.CommandContext(ctx, ...)
Set working dir / envcmd.Dir / cmd.Env
Get the exit codeerrors.As(err, &exec.ExitError{})ExitCode()
Find a binary on PATHexec.LookPath(name)

⚠️ No shell means no shell features — and that's the point

Because there’s no shell, you don’t get $VAR expansion, ~ home-dir, glob *.go, pipes |, or redirection > for free — do those in Go (os.Getenv, filepath.Glob, wire pipes with io). Resist the urge to “fix” this with exec.Command("sh", "-c", userInput) — that re-opens the command-injection hole the design closed; only ever do it with a fully trusted, constant string. Also: always give long-running children a timeout via CommandContext, and remember a non-zero exit is a normal error to handle, not a panic.

See also

  • files & os — wire a child’s streams to files; os.Environ for its environment.
  • CLI Tools with flag — parse your own flags, then shell out to other tools.
  • contextCommandContext for deadlines and cancellation.
  • fmt & iocmd.Stdout/Stdin accept any io.Writer/io.Reader.

Next: prove your code works before it ships — Testing Basics.

Check your understanding

Score: 0 / 5

1. exec.Command("ls", "-l") — does the second argument get split by spaces like a shell?

os/exec does NOT invoke a shell. exec.Command(name, arg1, arg2, …) execs the program directly with those exact args — no globbing, no $VAR expansion, no word-splitting. That's why it's immune to shell-injection: user input becomes one argument, never code.

2. What's the difference between cmd.Run() and cmd.Output()?

Run() = Start() then Wait(); it returns an error but you must wire up Stdout yourself to see output. Output() is a convenience that captures stdout into a []byte and returns it (still erroring on failure). CombinedOutput() merges stdout+stderr.

3. A command exits with status 1. How do you get that exit code in Go?

A non-zero exit makes Run/Output return an error of concrete type *exec.ExitError. Assert it (errors.As) and call .ExitCode(); its .Stderr holds captured stderr when you used Output(). A nil error means the program exited 0.

4. How do you stop a child process if it runs too long?

exec.CommandContext binds the command to a context. When the context is cancelled or its deadline passes, os/exec sends the process a kill signal. Pair it with context.WithTimeout for a hard cap on runtime.

5. exec.Command("mytool", ...) — where does Go look for "mytool"?

If the name contains no path separator, exec.Command resolves it on $PATH using exec.LookPath. A relative or absolute path (./tool, /usr/bin/tool) is used as-is. Not found → the error wraps exec.ErrNotFound.

Comments

Sign in with GitHub to join the discussion.