os/exec Orchestration

Updated

September 8, 2026

os/exec Orchestration

Overview

CLIs often wrap other tools: git, docker, kubectl, compilers. Package os/exec runs subprocesses with explicit argv (never shell strings with untrusted input).

Basics

cmd := exec.CommandContext(ctx, "git", "status", "--short")
cmd.Dir = repoDir
cmd.Env = append(os.Environ(), "LC_ALL=C")
out, err := cmd.Output() // stdout; stderr in ExitError.Stderr if failed
Method Captures
Run exit only; you wire Stdout/Stderr
Output stdout bytes; err on non-zero
CombinedOutput stdout+stderr merged
Start/Wait async

Stream to the user

cmd := exec.Command("go", "test", "./...")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()

Capture and inspect exit code

err := cmd.Run()
if err != nil {
    var ee *exec.ExitError
    if errors.As(err, &ee) {
        return fmt.Errorf("git exit %d", ee.ExitCode())
    }
    return err // not found, killed, etc.
}

Pipes between processes

func pipeline(ctx context.Context) error {
    ps := exec.CommandContext(ctx, "ps", "aux")
    grep := exec.CommandContext(ctx, "grep", "go")

    r, w := io.Pipe()
    ps.Stdout = w
    grep.Stdin = r
    var grepOut bytes.Buffer
    grep.Stdout = &grepOut

    if err := ps.Start(); err != nil {
        return err
    }
    if err := grep.Start(); err != nil {
        return err
    }
    errPs := ps.Wait()
    _ = w.Close() // signal EOF to grep
    errGrep := grep.Wait()
    if errPs != nil {
        return errPs
    }
    if errGrep != nil {
        return errGrep
    }
    fmt.Print(grepOut.String())
    return nil
}

Prefer shell for one-off scripts; prefer argv pipelines in production wrappers for safety.

Never shell untrusted input

// BAD
exec.Command("sh", "-c", "echo "+userInput)

// GOOD
exec.Command("echo", userInput)

If you must use a shell, allowlist and escape carefully—usually you should not.

LookPath

path, err := exec.LookPath("docker")
if err != nil {
    return fmt.Errorf("docker not installed: %w", err)
}
_ = path

Example: gofmt-write wrapper

func run(paths []string) error {
    args := append([]string{"-w"}, paths...)
    cmd := exec.Command("gofmt", args...)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    return cmd.Run()
}

Example: parallel commands with errgroup

eg, ctx := errgroup.WithContext(context.Background())
for _, pkg := range packages {
    pkg := pkg
    eg.Go(func() error {
        cmd := exec.CommandContext(ctx, "go", "test", pkg)
        out, err := cmd.CombinedOutput()
        if err != nil {
            return fmt.Errorf("%s: %s: %w", pkg, out, err)
        }
        return nil
    })
}
return eg.Wait()

Bound parallelism if the child tools are heavy.

Example: runjson — exec and parse JSON stdout

cmd := exec.CommandContext(ctx, "docker", "inspect", id)
out, err := cmd.Output()
if err != nil {
    return err
}
var payload any
if err := json.Unmarshal(out, &payload); err != nil {
    return fmt.Errorf("parse docker json: %w", err)
}

Timeouts

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "make", "test")
err := cmd.Run()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
    return fmt.Errorf("timed out")
}

CommandContext sends kill when ctx cancels (details differ by OS for process groups).

Rules of thumb

Do Don’t
Argv slices sh -c with user strings
CommandContext Untimed long children
Surface exit codes Swallow ExitError without code
Set Dir/Env explicitly Rely on ambient cwd silently

Try next

  1. Wrap git rev-parse --short HEAD and print version.
  2. Run go test with timeout 5s; assert cancel path.
  3. Build whichall that LookPaths a list of tools and exits 1 if any missing.