Exit Codes, Stdout, and Stderr
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 jqmytool 2>log.txt | jq . # logs aside, data through pipeQuiet / 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_COLORunset) - 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
- Build a tool that prints JSON on stdout and timing on stderr; pipe through
jq. - Add
--quietthat silences stderr progress only. - Verify exit codes in a shell:
go run . bad; echo $?.