Exit Codes, Stdout, and Stderr

Updated

September 8, 2026

Exit Codes, Stdout, and Stderr

Overview

Automation treats your CLI as a machine API: stdout is data, stderr is diagnostics, exit code is success/failure. Break that contract and scripts, CI, and pipes fail in confusing ways.

Also see Unix pipeline CLI tools.

The contract

exit 0     success
exit 1     runtime failure (file missing, network error)
exit 2     misuse (bad flags, wrong arity) — common convention
exit 130   interrupted by SIGINT (128+2) — platform dependent

Go’s flag with ExitOnError exits 2 on parse errors—good.

func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintln(os.Stderr, "mytool:", err)
        var code int
        switch {
        case errors.Is(err, errUsage):
            code = 2
        default:
            code = 1
        }
        os.Exit(code)
    }
}

Stdout vs stderr

Stream Put here
stdout JSON results, tables meant for pipes, primary output
stderr Usage, progress, logs, warnings
fmt.Fprintln(os.Stderr, "fetching...") // progress
fmt.Println(string(jsonBytes))         // data for jq
mytool 2>log.txt | jq .               # logs aside, data through pipe

Quiet / verbose / JSON modes

type mode int

const (
    modeText mode = iota
    modeJSON
)

func run(stdout, stderr io.Writer, verbose bool, m mode, data Result) error {
    if verbose {
        fmt.Fprintln(stderr, "processing", data.ID)
    }
    if m == modeJSON {
        enc := json.NewEncoder(stdout)
        enc.SetIndent("", "  ")
        return enc.Encode(data)
    }
    fmt.Fprintf(stdout, "%s\t%d\n", data.Name, data.Count)
    return nil
}

Is stdout a TTY?

func stdoutIsTTY() bool {
    fi, err := os.Stdout.Stat()
    if err != nil {
        return false
    }
    return fi.Mode()&os.ModeCharDevice != 0
}

Use TTY detection for:

  • color (only if TTY and NO_COLOR unset)
  • progress bars
  • human tables vs TSV for pipes
if stdoutIsTTY() && os.Getenv("NO_COLOR") == "" {
    // optional color codes
}

Respect NO_COLOR when you add color (stdlib has no color package—use raw ANSI sparingly or a small lib later).

Don’t call os.Exit in libraries

// package greeter
func Greet(w io.Writer, name string) error { ... }

// package main
func main() {
    if err := greeter.Greet(os.Stdout, name); err != nil {
        os.Exit(1)
    }
}

os.Exit skips deferred cleanup in the current goroutine’s stack for the process—keep it at the edge.

Example: correct grep-shaped output

// matches to stdout, errors to stderr, exit 0 if any match, 1 if none (grep-like)
matched := false
for _, line := range lines {
    if strings.Contains(line, pattern) {
        fmt.Fprintln(stdout, line)
        matched = true
    }
}
if !matched {
    return errNoMatch // main maps to exit 1
}

Document whether you follow grep’s exit semantics—they surprise some users.

Machine-readable errors

type cliError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

// when --json-errors and failing:
json.NewEncoder(os.Stderr).Encode(cliError{Code: 1, Message: err.Error()})

Rules of thumb

Do Don’t
Data → stdout Mix log lines into JSON stdout
Diagnostics → stderr Require users to scrape stdout for errors
Non-zero on failure Always exit 0 and bury errors in text
Map usage errors to 2 Use exit 1 for everything without docs

Try next

  1. Build a tool that prints JSON on stdout and timing on stderr; pipe through jq.
  2. Add --quiet that silences stderr progress only.
  3. Verify exit codes in a shell: go run . bad; echo $?.