🔧 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 separate — exec.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
| Goal | Call |
|---|---|
| Build a command | exec.Command(name, args...) |
| Run, wait, error only | cmd.Run() |
| Capture stdout | cmd.Output() → []byte |
| Capture stdout+stderr | cmd.CombinedOutput() |
| Run asynchronously | cmd.Start() then cmd.Wait() |
| Stream output live | set cmd.Stdout/cmd.Stderr |
| Read output incrementally | cmd.StdoutPipe() |
| Feed input | set cmd.Stdin |
| Add a timeout | exec.CommandContext(ctx, ...) |
| Set working dir / env | cmd.Dir / cmd.Env |
| Get the exit code | errors.As(err, &exec.ExitError{}) → ExitCode() |
| Find a binary on PATH | exec.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.Environfor its environment. - CLI Tools with flag — parse your own flags, then shell out to other tools.
- context —
CommandContextfor deadlines and cancellation. - fmt & io —
cmd.Stdout/Stdinaccept anyio.Writer/io.Reader.
Next: prove your code works before it ships — Testing Basics.
Related topics
Reading and writing files with os — whole-file vs streaming, OpenFile flags and permission bits, directories and FileInfo, portable paths via path/filepath (and filepath.WalkDir), and the os entry points (args, env, std streams, Exit).
systemCLI Tools with flagBuild command-line tools with the flag package — typed options with defaults, binding with Var, positional args, subcommands via FlagSet, custom flag.Value types, and env-var fallbacks.
Check your understanding
Score: 0 / 51. 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.