Command-Line Programs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. A Go program is a single static executable that the operating system starts with a list of arguments, an environment, three open streams and a way to deliver signals, and that reports back with an exit status. That is a small contract, and the standard library covers every part of it: os.Args and flag for the arguments, os for the environment and the streams, bufio for efficient line input and output, os/signal for interruption, os/exec for starting other programs and os.Exit for the status. This page covers all of them, shows the traps that each one hides, and finishes by building the cmd/shelf program of the running shelf scenario: search, lend, return, import and export, with exit codes, a testable structure and graceful interruption.

It builds on Functions, Closures and Defer (why os.Exit skips deferred calls), Error Handling (errors.Is/errors.As, which map errors to statuses here) and Context (cancellation, signal.NotifyContext and exec.CommandContext from the context side). The related pages are Packages, Imports and Initialization (the main package and init), I/O, Files and the Filesystem (io.Reader, io.Writer, files), fmt, Strings and Text Processing (Fprintf, Sscanf, text/tabwriter), Structured Logging with slog, encoding/json, The go Command, Builds and Cross-Compilation (-ldflags -X, -trimpath, go install), Testing and net/http Clients and Servers (graceful shutdown of a server). The same ground in other languages is covered in Rust I/O, Files, Processes and Environment and C Input, Output and Files.

The standard library is deliberately enough for most tools, and this page stops there. For a command tree with generated help and shell completion (Cobra) and for terminal user interfaces (Bubble Tea) see Go & Rust CLI Frameworks, which is not repeated here; the design of a CLI that people, scripts and AI agents can all use is the subject of the CLI for Agents section, linked where it applies.

Every example was compiled, vetted with go vet and run with Go 1.27.2 on macOS (arm64); the output shown is the real output, and the compiler message is copied from a real run. The signal, pipe and process examples use Unix facilities (SIGPIPE, kill, sh, sleep) and therefore run on Linux and macOS as shown; the Windows differences are called out where they matter. Each example is a package main in its own directory of the example module, built into the module root with go build ./cmd/<name>, so that the transcripts show ./name as the program name. The go.mod of the module is:

module example.com/shelf

go 1.27

The anatomy of a command-line program

Whatever a Go program does, its conversation with the outside world has the same shape, drawn in the figure: the caller provides arguments, an environment, standard input and signals; the program answers with standard output, standard error, files, an exit status and, if it starts other programs, a second conversation of the same shape with each child.

Diagram of a command-line program: arguments, environment, standard input and signals enter a shelf process; standard output, standard error, an exit status and files leave it; child processes started with os/exec hang below it

The standard library packages that own each part:

Package What it provides Section

os

Args, Stdin/Stdout/Stderr, Exit, Getenv/LookupEnv/Setenv/Environ, Executable, UserConfigDir, Interrupt/Kill, Process

Program arguments, Environment variables

flag

Command-line flags, FlagSet, usage text, Value

The flag package, Subcommands with FlagSet

bufio, io

Scanner, Reader, Writer, io.Copy, io.ReadAll

Standard streams

os/signal, syscall

Notify, NotifyContext, Stop, Ignore, signal constants

Signals and graceful shutdown

os/exec

Command, CommandContext, Cmd, LookPath, ExitError, ErrDot

Running other programs

fmt, log, log/slog, text/tabwriter

Output formatting, diagnostics to standard error, aligned tables

Standard streams, Building the shelf CLI

runtime/debug

ReadBuildInfo: module version, VCS revision, build settings

Building the shelf CLI

One design principle makes every other piece of this page easier: keep main almost empty. main builds the real dependencies (the three streams, the environment, a signal-aware context), calls a function that returns an int, and passes that value to os.Exit. Everything else, including parsing, validation, work and the translation of errors into statuses, lives in run, which tests can call with fake streams and a fake environment. The final sections build the shelf CLI exactly this way.

Program arguments: os.Args

os.Args is a []string. os.Args[0] is the name the program was started with (a path as typed, a name looked up on PATH, or the temporary executable that go run builds), and os.Args[1:] are the arguments, exactly as the operating system delivered them. On Unix the shell has already done the quoting, variable expansion and wildcard expansion, so 'a b' arrives as one argument and *.go arrives as the list of matching files; on Windows the process receives one command-line string, which the Go runtime splits into os.Args, and no wildcard expansion happens at all. A Go program never sees the quotes.

cmd/args/main.go
package main

import (
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	fmt.Println("program:", filepath.Base(os.Args[0]))
	fmt.Println("count:  ", len(os.Args)-1)
	for i, a := range os.Args[1:] {
		fmt.Printf("arg %d: %q\n", i+1, a)
	}
}
$ ./args 'a b' '' 'c"d'
program: args
count:   3
arg 1: "a b"
arg 2: ""
arg 3: "c\"d"
$ go run ./cmd/args one two | head -2
program: args
count:   2

Source: os: Args

Three habits make os.Args safe:

  • Check the length before indexing. os.Args[1] panics with index out of range [1] with length 1 when the user typed nothing. Test len(os.Args) or, better, let flag do the parsing and test flag.NArg().

  • Arguments are bytes, not necessarily UTF-8. A file name on Linux can hold any bytes, and os.Args carries them unchanged; see Strings, Runes and UTF-8 before assuming range yields valid characters.

  • Use os.Executable, not os.Args[0], to find the program’s own file. os.Args[0] can be a bare name that is only meaningful to the shell that resolved it. os.Executable returns an absolute path to the executable that started the process (it may be the symbolic link that was used, depending on the operating system, so use filepath.EvalSymlinks when a stable location matters, and there is no guarantee that the file still exists). Its documented use is finding resources located next to the executable.

Reading os.Args directly is fine for a program with a fixed shape, such as tool <file>. As soon as options appear, use the flag package.

The flag package

flag implements the traditional single-dash option syntax used by the Go toolchain itself (go build -o out -v ./...). It is small, has no dependencies and is complete enough for most tools. What it deliberately lacks are the things that Cobra and similar libraries add: short and long aliases for one option (-v and --verbose), grouped short options (-abc), options after positional arguments, and a command tree. The sections below cover what it does have in full.

Flag syntax

Form Meaning

-flag

Boolean flag: sets it to true.

--flag

Identical to -flag: one or two dashes may be used.

-flag=x

Any flag, value x. The only form that can set a boolean to false (-json=false).

-flag x

Non-boolean flags only: the next argument is the value.

-

Not a flag: a lone dash is a positional argument, conventionally meaning standard input.

--

Terminator: everything after it is a positional argument, even if it starts with a dash.

Parsing stops at the first argument that is not a flag, so flags must come before positional arguments: shelf -limit 5 go works, shelf go -limit 5 treats -limit and 5 as positional. Boolean flags take their value only with =: -json false sets json to true and leaves false as a positional argument, because otherwise cmd -x * would change meaning when a file called false or 0 exists. Integer flags accept decimal, octal (0664), hexadecimal (0x1234) and negative values; boolean flags accept 1, 0, t, f, T, F, true, false, TRUE, FALSE, True and False; duration flags accept anything time.ParseDuration does (250ms, 1h30m).

Defining flags

Each type has two constructors: one that returns a pointer (flag.Int returns *int) and one that binds an existing variable (flag.IntVar(&n, ...)). Both exist as package functions, which act on the default set flag.CommandLine, and as methods on a FlagSet.

Pointer form Variable form Value syntax

flag.Bool

flag.BoolVar

-x, -x=false

flag.Int, flag.Int64

flag.IntVar, flag.Int64Var

decimal, 0x, 0 octal, negative

flag.Uint, flag.Uint64

flag.UintVar, flag.Uint64Var

non-negative integer

flag.Float64

flag.Float64Var

3.5, 1e3

flag.String

flag.StringVar

any text

flag.Duration

flag.DurationVar

250ms, 1h30m

(none)

flag.TextVar (Go 1.19)

anything the variable’s UnmarshalText accepts: slog.Level, netip.Addr, time.Time, big.Int

(none)

flag.Func (Go 1.16), flag.BoolFunc (Go 1.21)

calls your function with the text of every occurrence

(none)

flag.Var

any type implementing flag.Value

Every constructor takes (name, default, usage). Constructors panic at start-up, not at parse time, when a definition is wrong, which is why a mistake surfaces on the very first run:

  • a name that begins with - or contains = panics (the check was added in Go 1.17);

  • defining the same name twice panics with flag redefined: v, or shelf search flag redefined: v for a named FlagSet;

  • since Go 1.21, defining a flag after Set was already called for that name panics too.

A first example

The search command of the shelf program needs a text to look for, a limit, a switch for JSON output and a timeout.

cmd/flagbasics/main.go
package main

import (
	"flag"
	"fmt"
	"os"
	"time"
)

func main() {
	var (
		title   = flag.String("title", "", "match books whose title contains `text`")
		limit   = flag.Int("limit", 10, "maximum number of results")
		asJSON  = flag.Bool("json", false, "print results as JSON")
		timeout = flag.Duration("timeout", 5*time.Second, "give up after this long")
	)
	var verbose bool
	flag.BoolVar(&verbose, "v", false, "verbose output")

	flag.Parse()

	fmt.Printf("title=%q limit=%d json=%t timeout=%v verbose=%t\n", *title, *limit, *asJSON, *timeout, verbose)
	fmt.Printf("NFlag=%d NArg=%d Args=%q\n", flag.NFlag(), flag.NArg(), flag.Args())
	if flag.NArg() > 0 {
		fmt.Fprintln(os.Stdout, "first positional:", flag.Arg(0))
	}
}

The back-quoted word in a usage string ( text ) names the value in the help output. Running it shows the forms from the syntax table, including the two ways that a boolean flag surprises people:

$ ./flagbasics -title go -limit 3
title="go" limit=3 json=false timeout=5s verbose=false
NFlag=2 NArg=0 Args=[]
$ ./flagbasics --limit=3 -json -v rest -v
title="" limit=3 json=true timeout=5s verbose=true
NFlag=3 NArg=2 Args=["rest" "-v"]
first positional: rest
$ ./flagbasics -json=false -timeout 250ms -- -title x
title="" limit=10 json=false timeout=250ms verbose=false
NFlag=2 NArg=2 Args=["-title" "x"]
first positional: -title
$ ./flagbasics -json false x
title="" limit=10 json=true timeout=5s verbose=false
NFlag=1 NArg=2 Args=["false" "x"]
first positional: false

Reading the transcript: the second run shows rest and the second -v landing in flag.Args() because parsing stopped at rest; the third shows -- protecting -title and x from parsing; the fourth shows the boolean pitfall, where false became a positional argument and json is true. flag.NFlag() counts the flags that were set, flag.NArg() and flag.Args() describe what remains.

Errors, usage text and -h

When parsing fails, or when the user passes -h or -help and no such flag is defined, the FlagSet prints the problem and the usage text and then acts according to its error handling value:

Value What Parse does after printing the message

flag.ExitOnError

Calls os.Exit(2); for -h/-help it calls os.Exit(0) (changed from 2 to 0 in Go 1.15). This is the default for the command line (flag.CommandLine) and therefore for flag.Parse().

flag.ContinueOnError

Returns the error (flag.ErrHelp for -h/-help). This is the zero value, so a FlagSet created with new or as a zero struct uses it. The right choice for subcommands and for tests.

flag.PanicOnError

Panics with the error.

Both the error message and the usage go to standard error by default (FlagSet.Output is os.Stderr unless SetOutput changed it), including for -h: asking for help prints nothing on standard output, which is why ./flagbasics -h 2>/dev/null below prints nothing. (Many tools print help to standard output when the user asked for it; the standard library does not.)

$ ./flagbasics -limit abc
invalid value "abc" for flag -limit: parse error
Usage of ./flagbasics:
  -json
    	print results as JSON
  -limit int
    	maximum number of results (default 10)
  -timeout duration
    	give up after this long (default 5s)
  -title text
    	match books whose title contains text
  -v	verbose output
[exit status 2]
$ ./flagbasics -timeout
flag needs an argument: -timeout
Usage of ./flagbasics:
  -json
    	print results as JSON
  -limit int
    	maximum number of results (default 10)
  -timeout duration
    	give up after this long (default 5s)
  -title text
    	match books whose title contains text
  -v	verbose output
[exit status 2]
$ ./flagbasics -nope 2>&1 | head -3
flag provided but not defined: -nope
Usage of ./flagbasics:
  -json
$ ./flagbasics -h; echo status $?
Usage of ./flagbasics:
  -json
    	print results as JSON
  -limit int
    	maximum number of results (default 10)
  -timeout duration
    	give up after this long (default 5s)
  -title text
    	match books whose title contains text
  -v	verbose output
status 0
$ ./flagbasics -h 2>/dev/null | wc -c
       0

The usage text is generated by PrintDefaults: flags sorted by name, the type or the back-quoted word after the name, the usage on the next line unless it is a boolean with a one-byte name, and (default ...) omitted when the default is the zero value. flag.UnquoteUsage exposes the parsing of the back-quoted name for custom formatting.

Source: flag: FlagSet

Custom flag types: Value, Func, BoolFunc and TextVar

A flag’s value is anything that implements flag.Value: String() string and Set(string) error. Set is called once, in command-line order, for each occurrence, so a type can accumulate (a repeatable flag) or validate (return an error that the parser reports as invalid value "x" for flag -year: ...). If the type also has IsBoolFlag() bool returning true, -name alone means -name=true. The optional flag.Getter interface adds Get() any; every value type of the package implements it except the two behind Func and BoolFunc.

Most custom flags do not need a type any more:

  • flag.Func(name, usage, fn) (Go 1.16) calls fn with the value each time the flag appears; an error from fn is a parse error. Use it to validate, to parse into a struct or to append to a slice.

  • flag.BoolFunc(name, usage, fn) (Go 1.21) is the same for a flag that takes no value, such as -v that raises the verbosity each time it is repeated. fn receives the text "true" for -v and the explicit text for -v=false.

  • flag.TextVar(&v, name, default, usage) (Go 1.19) binds any variable whose pointer implements encoding.TextUnmarshaler, with a default that implements encoding.TextMarshaler: slog.Level, netip.Addr, time.Time, *big.Int and your own types, with no glue code.

cmd/flagtypes/main.go
package main

import (
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"strconv"
	"strings"
)

// authors is a repeatable flag: -author A -author B. It implements flag.Value.
type authors []string

func (a *authors) String() string { return strings.Join(*a, ",") }

func (a *authors) Set(s string) error {
	if strings.TrimSpace(s) == "" {
		return errors.New("author name must not be empty")
	}
	*a = append(*a, s)
	return nil
}

func main() {
	fs := flag.NewFlagSet("shelf search", flag.ContinueOnError)
	fs.SetOutput(os.Stdout) // usage and parse errors go to stdout so the demo is easy to capture

	var by authors
	fs.Var(&by, "author", "restrict to `name` (repeatable)")

	verbosity := 0
	fs.BoolFunc("v", "increase verbosity (repeatable)", func(string) error {
		verbosity++
		return nil
	})

	var year int
	fs.Func("year", "publication `year` between 1450 and 2100", func(s string) error {
		n, err := strconv.Atoi(s)
		if err != nil {
			return errors.New("not a number")
		}
		if n < 1450 || n > 2100 {
			return fmt.Errorf("%d is out of range", n)
		}
		year = n
		return nil
	})

	level := slog.LevelInfo
	fs.TextVar(&level, "log-level", slog.LevelInfo, "log `level`: debug, info, warn or error")

	if err := fs.Parse(os.Args[1:]); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return
		}
		fmt.Println("parse failed:", err)
		os.Exit(2)
	}

	fmt.Printf("authors=%q verbosity=%d year=%d level=%v\n", by, verbosity, year, level)

	fmt.Print("set explicitly:")
	fs.Visit(func(f *flag.Flag) { fmt.Print(" ", f.Name) })
	fmt.Println()

	f := fs.Lookup("log-level")
	fmt.Printf("log-level: default %q, current %q\n", f.DefValue, f.Value)
}
$ ./flagtypes -h
Usage of shelf search:
  -author name
    	restrict to name (repeatable)
  -log-level level
    	log level: debug, info, warn or error (default INFO)
  -v	increase verbosity (repeatable)
  -year year
    	publication year between 1450 and 2100
$ ./flagtypes -author Knuth -author Pike -v -v -v -year 1978 -log-level=debug
authors=["Knuth" "Pike"] verbosity=3 year=1978 level=DEBUG
set explicitly: author log-level v year
log-level: default "INFO", current "DEBUG"
$ ./flagtypes -year 1200 | head -1
invalid value "1200" for flag -year: 1200 is out of range
$ ./flagtypes -log-level loud | head -1
invalid value "loud" for flag -log-level: slog: level string "loud": unknown name

Two details in that output are worth noticing. The default for the TextVar flag is shown as INFO, produced by the MarshalText of slog.Level, and the error for a bad level comes straight from slog. And the program printed the parse error twice when run without | head -1: the FlagSet already wrote the message and the usage to its output, so the caller must not print the error again; in the shelf program below, parse failures are turned into a sentinel that means "already reported".

Custom usage text, required flags and detecting explicit flags

flag.Usage is a variable of type func(): assign a function to control the help text of the command line. A FlagSet has the same field, FlagSet.Usage. The function runs for -h and for every parse error, and it may print to flag.CommandLine.Output() so that a later SetOutput is respected.

flag has no notion of a required flag. Check after parsing and fail with the same status the package uses, 2. And since a flag that was not given has its default value, flag.Visit (which walks only the flags that were set) tells -days 14 apart from the default of 14; flag.VisitAll walks every defined flag.

cmd/usage/main.go
package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	name := filepath.Base(os.Args[0])
	member := flag.String("member", "", "member `id` (required)")
	days := flag.Int("days", 14, "loan length in `days`")

	// flag.Usage is a variable: replace it to control the help text. It is
	// called for -h/-help and for every parse error.
	flag.Usage = func() {
		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [flags] ISBN...\n\nLend books to a member.\n\nFlags:\n", name)
		flag.PrintDefaults()
		fmt.Fprintf(flag.CommandLine.Output(), "\nExample:\n  %s -member ana 978-0-13-419044-0\n", name)
	}
	flag.Parse() // the default error handling for the command line is ExitOnError

	// Flag.Parse cannot express "required": check it yourself, and exit with
	// the same status the flag package uses for a bad command line.
	if *member == "" || flag.NArg() == 0 {
		flag.Usage()
		os.Exit(2)
	}

	// Visit walks only the flags that were set on the command line, so it can
	// tell "-days 14" from the default of 14.
	explicit := map[string]bool{}
	flag.Visit(func(f *flag.Flag) { explicit[f.Name] = true })
	fmt.Printf("lending %q to %s for %d days (days set explicitly: %t)\n", flag.Args(), *member, *days, explicit["days"])
}
$ ./usage -h
Usage: usage [flags] ISBN...

Lend books to a member.

Flags:
  -days days
    	loan length in days (default 14)
  -member id
    	member id (required)

Example:
  usage -member ana 978-0-13-419044-0
$ ./usage
Usage: usage [flags] ISBN...

Lend books to a member.

Flags:
  -days days
    	loan length in days (default 14)
  -member id
    	member id (required)

Example:
  usage -member ana 978-0-13-419044-0
[exit status 2]
$ ./usage -member ana -days 14 978-0-13-419044-0 978-0-13-468599-1
lending ["978-0-13-419044-0" "978-0-13-468599-1"] to ana for 14 days (days set explicitly: true)
$ ./usage -member=ana 978-0-13-419044-0
lending ["978-0-13-419044-0"] to ana for 14 days (days set explicitly: false)

Source: flag: Visit

The rest of the API

Function or method Purpose

flag.Parse(), FlagSet.Parse(args)

Parse os.Args[1:] or the given slice. Define every flag before calling it.

flag.Parsed(), FlagSet.Parsed()

Whether parsing happened.

flag.Args(), flag.Arg(i), flag.NArg(), flag.NFlag()

The remaining positional arguments, one of them ("" if out of range), their number, and the number of flags set.

flag.Lookup(name)

The *flag.Flag (Name, Usage, Value, DefValue) of a defined flag, or nil. Useful to read the default as text.

flag.Set(name, value)

Set a defined flag programmatically, as if it had been given.

flag.Visit(fn), flag.VisitAll(fn)

Walk the flags that were set, or all flags, in lexicographical order.

flag.PrintDefaults(), flag.UnquoteUsage(f)

Print the default help body; split a usage string into the value name and the text.

flag.NewFlagSet(name, handling)

An independent set: for subcommands, tests and libraries that must not touch flag.CommandLine.

FlagSet.Init(name, handling), Name(), ErrorHandling()

Re-initialise a set, read back its name and handling.

FlagSet.SetOutput(w), Output()

Where messages go; os.Stderr when unset.

flag.CommandLine

The default *FlagSet, which the package-level functions wrap.

flag.ErrHelp

The error returned by Parse for -h/-help when no such flag is defined.

A draft of the Go 1.28 release notes, which is not released (Go 1.28 is expected in February 2027), mentions iterator methods for flag; nothing about them can be relied on yet, and the code on this page targets Go 1.27.

Pitfalls of the flag package

Pitfall What to do instead

Flags after the first positional argument are silently treated as positional

Document "flags first", reject unexpected leftovers with flag.NArg(), or write a small loop that re-parses fs.Args() when you want interleaving.

-json false leaves false as an argument and sets json to true

Always -json=false.

Forgetting that flag.Int returns a pointer

*limit, or bind a variable with flag.IntVar. The compiler catches most misuse (see below).

Defining flags inside a function that runs twice (tests, init plus main)

Define on a fresh FlagSet per run: a second definition of the same name panics.

Calling flag.Parse() in init or at package level

Parse in main (or run). Libraries should never call Parse: the program owns the command line.

Reading a flag variable before Parse

The variable still holds its default. Read flags only after parsing.

Printing the parse error again after ContinueOnError

The FlagSet already wrote it (and the usage) to its output; only choose the exit status.

Negative numbers as positional arguments

-1 looks like a flag. Put them after --.

Environment or config file that should override defaults

flag has no such feature: resolve it yourself, with the precedence flag, then environment, then default (see the environment section).

The compiler catches the pointer mistake, with a message that names both types:

main.go of a separate module, example.com/broken
package main

import (
	"flag"
	"fmt"
)

func main() {
	limit := flag.Int("limit", 10, "maximum number of results")
	flag.Parse()
	for i := 0; i < limit; i++ {
		fmt.Println("result", i)
	}
}
$ go build .
# example.com/broken
./main.go:11:18: invalid operation: i < limit (mismatched types int and *int)

Source: flag: Int

Subcommands with FlagSet

shelf search, shelf lend, shelf export and so on are subcommands: the first non-flag argument selects a command and the remaining arguments belong to it. The standard library has no command tree, but one flag.FlagSet per subcommand and a map from names to functions is all it takes. Three details make it robust:

  • Every subcommand gets its own FlagSet with ContinueOnError, named shelf <command> so that messages and usage say which command complained, and with its output set to the standard error of the surrounding program (not the global os.Stderr, so that tests can capture it).

  • Because flag stops at the first positional argument, global flags (-data, -version) go on a top-level set that is parsed first, and Args()[0] is the subcommand name. shelf -data x.json search go works and shelf search -data x.json go does not, which is the same rule git and go follow.

  • A parse error or a usage mistake is reported once and becomes exit status 2; -h is status 0; anything else a command returns is status 1.

The skeleton below contains only that structure, with two commands; the complete program is built later.

cmd/subcmds/main.go
package main

import (
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"sort"
)

// errUsage means "the user was already told what is wrong": the flag package
// printed its message and usage, or the command did. run maps it to exit status 2.
var errUsage = errors.New("usage error")

// command is one subcommand: a summary for the help list and a function that
// owns its FlagSet.
type command struct {
	summary string
	run     func(args []string, stdout, stderr io.Writer) error
}

func main() {
	os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}

func run(args []string, stdout, stderr io.Writer) int {
	commands := map[string]command{
		"search": {"find books", search},
		"lend":   {"lend a book to a member", lend},
	}

	if len(args) == 0 || args[0] == "-h" || args[0] == "-help" || args[0] == "help" {
		fmt.Fprintln(stderr, "usage: shelf <command> [flags]")
		names := make([]string, 0, len(commands))
		for name := range commands {
			names = append(names, name)
		}
		sort.Strings(names)
		for _, name := range names {
			fmt.Fprintf(stderr, "  %-8s %s\n", name, commands[name].summary)
		}
		if len(args) == 0 {
			return 2
		}
		return 0
	}

	cmd, ok := commands[args[0]]
	if !ok {
		fmt.Fprintf(stderr, "shelf: unknown command %q (try \"shelf help\")\n", args[0])
		return 2
	}

	err := cmd.run(args[1:], stdout, stderr)
	switch {
	case err == nil, errors.Is(err, flag.ErrHelp):
		return 0
	case errors.Is(err, errUsage):
		return 2
	default:
		fmt.Fprintf(stderr, "shelf %s: %v\n", args[0], err)
		return 1
	}
}

// parse runs fs.Parse and converts a failure into errUsage, because the
// FlagSet has already printed the problem and the usage text to its output.
func parse(fs *flag.FlagSet, args []string) error {
	if err := fs.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return err
		}
		return errUsage
	}
	return nil
}

func search(args []string, stdout, stderr io.Writer) error {
	fs := flag.NewFlagSet("shelf search", flag.ContinueOnError)
	fs.SetOutput(stderr)
	title := fs.String("title", "", "match books whose title contains `text`")
	limit := fs.Int("limit", 10, "maximum number of results")
	if err := parse(fs, args); err != nil {
		return err
	}
	fmt.Fprintf(stdout, "search title=%q limit=%d rest=%q\n", *title, *limit, fs.Args())
	return nil
}

func lend(args []string, stdout, stderr io.Writer) error {
	fs := flag.NewFlagSet("shelf lend", flag.ContinueOnError)
	fs.SetOutput(stderr)
	member := fs.String("member", "", "member `id` (required)")
	if err := parse(fs, args); err != nil {
		return err
	}
	if *member == "" || fs.NArg() != 1 {
		fmt.Fprintln(stderr, "usage: shelf lend -member ID ISBN")
		return errUsage
	}
	fmt.Fprintf(stdout, "lent %s to %s\n", fs.Arg(0), *member)
	return nil
}
$ ./subcmds help
usage: shelf <command> [flags]
  lend     lend a book to a member
  search   find books
$ ./subcmds search -title go -limit 2 extra
search title="go" limit=2 rest=["extra"]
$ ./subcmds lend
usage: shelf lend -member ID ISBN
[exit status 2]
$ ./subcmds search -limit zz
invalid value "zz" for flag -limit: parse error
Usage of shelf search:
  -limit int
    	maximum number of results (default 10)
  -title text
    	match books whose title contains text
[exit status 2]
$ ./subcmds frob
shelf: unknown command "frob" (try "shelf help")
[exit status 2]

Exit codes, os.Exit and deferred calls

A process ends with an integer status that the caller reads: shells expose it as $?, make and CI systems treat non-zero as failure, exec.Cmd returns it in an *exec.ExitError. The conventions are old and widely relied on:

Status Meaning

0

Success.

1

General failure. What log.Fatal and most hand-written os.Exit(1) calls report.

2

Misuse of the command line. The flag package exits with 2 on a parse error; an unrecovered panic and a runtime fatal error such as "all goroutines are asleep" also end with 2.

3 to 125

Free for the program’s own meanings. The shelf program below uses 3 for "the catalogue refused: unknown book, already on loan".

126, 127

Reserved by shells: found but not executable, command not found.

128 + n

Terminated by signal n, as reported by shells: 130 for SIGINT (Ctrl-C), 141 for SIGPIPE, 143 for SIGTERM. A program that handles the signal itself and then exits normally conventionally returns the same value, as the shutdown example below does.

os.Exit(code) ends the process immediately: deferred functions do not run, goroutines are not stopped, buffered writers are not flushed, and log.Fatal is just Print followed by os.Exit(1). Conversely, when main returns the program exits with status 0 even if other goroutines are still running. Two consequences follow:

  • never defer something important in a function that ends with os.Exit or log.Fatal; and

  • call os.Exit in exactly one place, main, after a function that owns all the deferred cleanup has returned.

On Unix the kernel keeps only the low eight bits of the status, so values outside 0 to 255 wrap around; the documentation advises staying in the range 0 to 125 for portability. A small program shows the wrap-around, and the flag-redefinition panic message from earlier:

cmd/exitrange/main.go
package main

import (
	"flag"
	"fmt"
	"os"
	"strconv"
)

func main() {
	if len(os.Args) > 1 && os.Args[1] == "redefine" {
		flag.Bool("v", false, "verbose")
		defer func() { fmt.Println("recovered:", recover()) }()
		flag.Bool("v", false, "verbose again") // panics: flag redefined
		return
	}
	code, _ := strconv.Atoi(os.Args[1])
	os.Exit(code)
}
$ ./exitrange redefine
./exitrange flag redefined: v
recovered: ./exitrange flag redefined: v
$ for c in 0 1 125 255 256 300 -1; do ./exitrange $c; echo "os.Exit($c) -> status $?"; done
os.Exit(0) -> status 0
os.Exit(1) -> status 1
os.Exit(125) -> status 125
os.Exit(255) -> status 255
os.Exit(256) -> status 0
os.Exit(300) -> status 44
os.Exit(-1) -> status 255

Source: os: Exit

The next program contrasts the four ways a Go program can end badly, selected by its first argument: os.Exit, log.Fatal, a panic and the run pattern. Its exitError type lets a command choose its status, and errors.As finds it even when the error was wrapped with %w on the way up.

cmd/exitcodes/main.go
package main

import (
	"errors"
	"fmt"
	"log"
	"os"
)

// exitError carries the exit status a failed command wants the process to use.
type exitError struct {
	code int
	err  error
}

func (e *exitError) Error() string { return e.err.Error() }
func (e *exitError) Unwrap() error { return e.err }

func main() {
	switch os.Args[1] {
	case "exit":
		defer fmt.Println("deferred: never printed")
		fmt.Println("calling os.Exit(3)")
		os.Exit(3)
	case "fatal":
		defer fmt.Println("deferred: never printed")
		log.SetFlags(0)
		log.Fatal("shelf: cannot open the catalogue")
	case "panic":
		defer fmt.Println("deferred: printed while the panic unwinds")
		var books map[string]int
		books["go"]++
	case "run":
		os.Exit(realMain())
	}
}

// realMain owns all the deferred cleanup; main only turns its result into a
// status, after realMain has returned and its defers have run.
func realMain() int {
	defer fmt.Println("deferred: flushed and closed everything")

	err := work()
	var ee *exitError
	switch {
	case err == nil:
		return 0
	case errors.As(err, &ee):
		fmt.Fprintln(os.Stderr, "shelf:", ee)
		return ee.code
	default:
		fmt.Fprintln(os.Stderr, "shelf:", err)
		return 1
	}
}

func work() error {
	return fmt.Errorf("lend: %w", &exitError{code: 4, err: errors.New("book is already on loan")})
}
$ ./exitcodes exit
calling os.Exit(3)
[exit status 3]
$ ./exitcodes fatal
shelf: cannot open the catalogue
[exit status 1]
$ ./exitcodes panic 2>&1 | head -4
deferred: printed while the panic unwinds
panic: assignment to entry in nil map

goroutine 1 [running]:
$ ./exitcodes run
shelf: book is already on loan
deferred: flushed and closed everything
[exit status 4]
$ ./exitcodes panic > /dev/null 2>&1; echo status $?
status 2

What the four runs show: os.Exit and log.Fatal skipped the deferred print, the panic ran it while unwinding and then printed a stack trace and exited with 2, and the run pattern printed the message, ran its defers (the line saying everything was flushed and closed) and then exited with the status the error asked for. The pattern costs two lines in main and removes a whole class of "the file was never closed" and "the buffer was never flushed" bugs.

Source: log: Fatal

Choose statuses deliberately and document them in the help text: scripts, CI pipelines and AI agents branch on them. The reasoning, and a longer list of conventions, are in Streams & Exit Codes. A related testing point: since Go 1.15 the value returned by TestMain is passed to os.Exit by the test wrapper, so a TestMain no longer needs to call os.Exit(m.Run()) itself (see Testing).

Standard streams

os.Stdin, os.Stdout and os.Stderr are ordinary *os.File values for file descriptors 0, 1 and 2, so they are an io.Reader, two io.Writer`s and a `Stat-able file. The Unix convention, which Go tools follow, is:

  • standard output carries the program’s result (the data a pipe or a redirect should receive);

  • standard error carries everything else: error messages, usage text, progress, warnings and logs.

Keeping the two apart is what lets shelf search zzz > out.txt leave a clean file and show the diagnostics on the terminal, and shelf export | jq keep working when a warning is printed. fmt.Fprintln(os.Stderr, ...) is the direct way; the log package writes to standard error by default (log.SetFlags(0) removes the timestamp for CLIs), and slog handlers should be pointed at os.Stderr for the same reason. The os.Stderr variable can be replaced in tests only if the code reads it through a field, which is why the shelf program keeps its streams in an app struct.

Reading input

Tool Use it for

bufio.Scanner

Line- or word-oriented input; the default for text. Scan returns false at end of input or on error: always check Err() afterwards. Lines are limited to 64 KiB (bufio.MaxScanTokenSize); a longer line stops the scan with bufio.ErrTooLong. Scanner.Buffer raises the limit. Split with bufio.ScanWords, ScanRunes or a custom function changes the token.

bufio.Reader

ReadString('\n') and ReadBytes for prompts and unbounded lines; ReadRune, Peek. The last line may come back together with io.EOF if it has no newline.

io.ReadAll(os.Stdin)

The whole input as one []byte (it replaced ioutil.ReadAll in Go 1.16). Fine for small input, an out-of-memory risk for unbounded input.

json.NewDecoder(os.Stdin)

A stream of JSON values, decoded as they arrive; see encoding/json.

io.Copy(dst, os.Stdin)

Pipe the input through unchanged without holding it in memory.

The first example reads standard input as a scanner, reports what kind of file it is and demonstrates the line limit.

cmd/stdin/main.go
package main

import (
	"bufio"
	"errors"
	"fmt"
	"os"
)

func main() {
	// Is standard input a terminal, a pipe or a file?
	if fi, err := os.Stdin.Stat(); err == nil {
		switch {
		case fi.Mode()&os.ModeCharDevice != 0:
			fmt.Println("stdin: a character device (a terminal, or /dev/null)")
		case fi.Mode()&os.ModeNamedPipe != 0:
			fmt.Println("stdin: a pipe")
		case fi.Mode().IsRegular():
			fmt.Println("stdin: a redirected file")
		}
	}

	sc := bufio.NewScanner(os.Stdin)
	if len(os.Args) > 1 && os.Args[1] == "big" {
		// The default limit is 64 KiB per line (bufio.MaxScanTokenSize).
		sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	}

	n, longest := 0, 0
	for sc.Scan() {
		n++
		longest = max(longest, len(sc.Text()))
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "read error:", err)
		fmt.Printf("too long: %t, lines read before the error: %d\n", errors.Is(err, bufio.ErrTooLong), n)
		os.Exit(1)
	}
	fmt.Printf("lines=%d longest=%d\n", n, longest)
}
$ printf 'go\nrust\nc\n' | ./stdin
stdin: a pipe
lines=3 longest=4
$ ./stdin < cmd/stdin/main.go
stdin: a redirected file
lines=40 longest=100
$ python3 -c 'print("x"*70000)' | ./stdin
stdin: a pipe
read error: bufio.Scanner: token too long
too long: true, lines read before the error: 0
[exit status 1]
$ python3 -c 'print("x"*70000)' | ./stdin big
stdin: a pipe
lines=1 longest=70000
$ ./stdin < /dev/null
stdin: a character device (a terminal, or /dev/null)
lines=0 longest=0

Source: bufio: Scanner

Two things in the transcript are worth remembering. A scanner that hits a too-long line reports it through Err(), not by crashing, but the lines after it are lost: the fix is Buffer, or a bufio.Reader. And /dev/null is a character device, just like a terminal, so ModeCharDevice is a heuristic for "interactive" and not proof of it. For a true terminal test (and for terminal size and raw mode) the Go project’s golang.org/x/term module provides term.IsTerminal; this page uses only the standard library. How a program should change its behaviour when its streams are not terminals (colour, progress bars, prompts) is covered in TTY, Colour & Interactivity.

An interactive prompt belongs on standard error, so that standard output stays machine-readable, and must treat end of input as an answer too. The prompt below defaults to "no":

cmd/confirm/main.go
package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"
)

// confirm asks a yes/no question on stderr (stdout stays clean for data) and
// reads the answer from in. End of input counts as "no".
func confirm(in *bufio.Reader, prompt string) (bool, error) {
	fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
	line, err := in.ReadString('\n')
	if err != nil && !errors.Is(err, io.EOF) {
		return false, err
	}
	answer := strings.ToLower(strings.TrimSpace(line))
	return answer == "y" || answer == "yes", nil
}

func main() {
	ok, err := confirm(bufio.NewReader(os.Stdin), "Return all 3 books on loan to ana?")
	if err != nil {
		fmt.Fprintln(os.Stderr, "shelf:", err)
		os.Exit(1)
	}
	if !ok {
		fmt.Fprintln(os.Stderr, "aborted")
		os.Exit(1)
	}
	fmt.Println("returned 3 books")
}
$ echo y | ./confirm
Return all 3 books on loan to ana? [y/N] returned 3 books
[exit status 0]
$ echo no | ./confirm
Return all 3 books on loan to ana? [y/N] aborted
[exit status 1]
$ printf '' | ./confirm
Return all 3 books on loan to ana? [y/N] aborted
[exit status 1]

The convention for programs that accept a file or standard input is: no file argument, or the argument -, means standard input. The import command of shelf does this, and io.Reader makes it one switch because both *os.File and os.Stdin satisfy it.

Writing output

os.Stdout is not buffered: every fmt.Println is a system call. A program that prints a lot should wrap it in a bufio.Writer, write through it, and check the error of Flush: it is where a full disk or a closed pipe is reported. That is also where Go’s treatment of SIGPIPE matters. When the reader of a pipe goes away (prog | head -2), the kernel sends SIGPIPE on the next write. By the os/signal documentation, a Go program that has not called Notify for SIGPIPE and writes to a broken pipe on file descriptor 1 or 2 is terminated by SIGPIPE, like a C program; a write to a broken pipe on any other descriptor just fails with EPIPE; and once the program has called Notify for SIGPIPE, every descriptor behaves the second way. So command-line programs behave like typical Unix tools by default, and servers do not crash when a client hangs up.

cmd/pipes/main.go
package main

import (
	"bufio"
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	if len(os.Args) > 1 && os.Args[1] == "handle-sigpipe" {
		// Once SIGPIPE is registered with Notify, a write to a closed pipe on
		// stdout or stderr returns EPIPE instead of killing the process.
		signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
	}
	os.Exit(run())
}

func run() int {
	out := bufio.NewWriter(os.Stdout)
	for i := 1; i <= 200000; i++ {
		if _, err := fmt.Fprintf(out, "978-0-13-%06d-0 title %d\n", i, i); err != nil {
			fmt.Fprintln(os.Stderr, "shelf: write:", err)
			return 1
		}
	}
	// Flush is where buffered write errors surface: always check it.
	if err := out.Flush(); err != nil {
		fmt.Fprintln(os.Stderr, "shelf: flush:", err)
		return 1
	}
	return 0
}
$ ./pipes | head -2
978-0-13-000001-0 title 1
978-0-13-000002-0 title 2
$ ./pipes handle-sigpipe | head -2
978-0-13-000001-0 title 1
978-0-13-000002-0 title 2
shelf: write: write /dev/stdout: broken pipe

In the first run head exited after two lines and the shell reported the status 141 (128 plus SIGPIPE 13) of the killed pipes process, which is exactly what yes | head -1 does. In the second run the Notify call turned the signal into an ordinary write error that the program reports and returns as status 1. For a filter that should end quietly when its output closes, either behaviour can be right; the important part is to decide, and to check the Flush error.

text/tabwriter aligns columns for human-readable listings, as the search command of shelf does, and encoding/json with SetIndent produces the machine-readable form behind a -json flag; offering both, and sending only the data to standard output, is the heart of Machine-Readable Output. NO_COLOR, TERM=dumb and the --no-color flag are conventions for turning colour off; the standard library has no colour support and a program that adds it should check them and whether the stream is a terminal.

Environment variables

The environment is a list of KEY=value strings that every process inherits from its parent and passes to its children. It is the usual way to configure a program without flags (SHELF_DATA, HTTP_PROXY, NO_COLOR), and the standard way for a deployment platform to hand over settings.

Function Behaviour

os.Getenv(key)

The value, or "" when unset: it cannot tell unset from set to the empty string.

os.LookupEnv(key)

(value, ok): ok is false only when the variable is unset. Use it whenever "empty" and "missing" mean different things.

os.Setenv(key, value), os.Unsetenv(key), os.Clearenv()

Change this process’s environment (and what children started later inherit). Nothing outside the process changes.

os.Environ()

A copy of the environment as KEY=value strings, in unspecified order: sort it before printing.

os.ExpandEnv(s), os.Expand(s, mapping)

Replace $var and ${var}; unset variables become the empty string. Expand takes any lookup function, so it also expands from a map.

os.UserHomeDir(), os.UserConfigDir(), os.UserCacheDir()

Per-user directories. The configuration directory is $XDG_CONFIG_HOME or $HOME/.config on Unix, $HOME/Library/Application Support on macOS and %AppData% on Windows; the cache directory is the analogous $XDG_CACHE_HOME, $HOME/Library/Caches and %LocalAppData%. Create an application subdirectory inside them.

os.TempDir()

$TMPDIR or /tmp on Unix. Use os.CreateTemp and os.MkdirTemp rather than inventing names in it.

os.Hostname(), os.Getwd(), os.Executable()

Host name, working directory, path of the running program.

On Windows, environment variable names are case-insensitive; elsewhere they are case-sensitive. The example program below resolves one setting with the usual precedence, flag, then environment, then built-in default, and exercises the functions. (It was run with HOME set explicitly, and the last two lines show the macOS answers; on Linux they are /home/ana/.config and /home/ana/.cache.)

cmd/envvars/main.go
package main

import (
	"fmt"
	"os"
	"slices"
	"strings"
	"time"
)

// setting resolves one option: an explicit flag wins, then the environment,
// then the built-in default.
func setting(flagValue, envName, def string) (value, source string) {
	if flagValue != "" {
		return flagValue, "flag"
	}
	if v, ok := os.LookupEnv(envName); ok && v != "" {
		return v, "environment"
	}
	return def, "default"
}

func main() {
	// Getenv cannot tell "unset" from "set to the empty string"; LookupEnv can.
	fmt.Printf("Getenv(SHELF_EMPTY)=%q\n", os.Getenv("SHELF_EMPTY"))
	_, set := os.LookupEnv("SHELF_EMPTY")
	_, missing := os.LookupEnv("SHELF_MISSING")
	fmt.Println("SHELF_EMPTY set:", set, "SHELF_MISSING set:", missing)

	dir, src := setting("", "SHELF_DATA", "./shelf-data")
	fmt.Printf("data dir: %s (from %s)\n", dir, src)
	dir, src = setting("/tmp/from-flag", "SHELF_DATA", "./shelf-data")
	fmt.Printf("data dir: %s (from %s)\n", dir, src)

	d, err := time.ParseDuration(os.Getenv("SHELF_TIMEOUT"))
	fmt.Println("SHELF_TIMEOUT:", d, err)

	// Setenv changes only this process (and the children it starts later).
	os.Setenv("SHELF_COLOR", "never")
	fmt.Println("after Setenv:", os.Getenv("SHELF_COLOR"))
	os.Unsetenv("SHELF_COLOR")
	_, still := os.LookupEnv("SHELF_COLOR")
	fmt.Println("after Unsetenv, set:", still)

	// Environ returns a copy of "key=value" strings.
	var mine []string
	for _, kv := range os.Environ() {
		if strings.HasPrefix(kv, "SHELF_") {
			mine = append(mine, kv)
		}
	}
	slices.Sort(mine) // the order of Environ is not specified
	fmt.Println("SHELF_* variables:", mine)

	// Expand and ExpandEnv substitute $var and ${var}; unset variables become "".
	fmt.Println(os.ExpandEnv("data in ${SHELF_DATA}/books.json, user=$SHELF_MISSING."))
	fmt.Println(os.Expand("$title by $author", func(k string) string {
		return map[string]string{"title": "TGPL", "author": "Donovan"}[k]
	}))

	home, _ := os.UserHomeDir()
	cfg, _ := os.UserConfigDir()
	cache, _ := os.UserCacheDir()
	fmt.Println("home:", home)
	fmt.Println("config:", cfg)
	fmt.Println("cache:", cache)
}
$ SHELF_EMPTY= SHELF_DATA=/srv/shelf SHELF_TIMEOUT=90s HOME=/home/ana ./envvars
Getenv(SHELF_EMPTY)=""
SHELF_EMPTY set: true SHELF_MISSING set: false
data dir: /srv/shelf (from environment)
data dir: /tmp/from-flag (from flag)
SHELF_TIMEOUT: 1m30s <nil>
after Setenv: never
after Unsetenv, set: false
SHELF_* variables: [SHELF_DATA=/srv/shelf SHELF_EMPTY= SHELF_TIMEOUT=90s]
data in /srv/shelf/books.json, user=.
TGPL by Donovan
home: /home/ana
config: /home/ana/Library/Application Support
cache: /home/ana/Library/Caches

Source: os: LookupEnv

Guidance for configuration through the environment:

  • Prefix your own variables (SHELF_) so they do not collide with others, and document each one in the help text.

  • Treat the environment as input to be validated, exactly like flags: the example parses SHELF_TIMEOUT with time.ParseDuration and a missing or malformed value is an error to handle, not a zero.

  • Do not put secrets in flags (they appear in process listings) and be careful with the environment (children inherit it, and on many systems it is readable by the same user’s other processes). Prefer a file with restrictive permissions, a credential helper, or standard input. The reasoning is in Configuration & Auth and Security; nothing on this page uses real credentials.

  • In tests, use t.Setenv, which restores the old value and forbids parallel tests, or inject a getenv function as the shelf program does, which is better still because it needs no global state.

  • Libraries should not read the environment on their own: take configuration as parameters and let main read the environment.

Signals and graceful shutdown

A signal is the operating system’s way to interrupt a process: the user pressed Ctrl-C, the terminal closed, an orchestrator wants a container to stop, a pipe broke. What a Go program does by default depends on the signal; os/signal changes it.

Signal Typical source Default behaviour of a Go program

SIGINT (os.Interrupt)

Ctrl-C

The program exits.

SIGTERM

kill, docker stop, Kubernetes, systemd

The program exits.

SIGHUP

The controlling terminal closed

The program exits.

SIGQUIT

Ctrl-\ (control-backslash)

The program exits with a goroutine stack dump. Useful to see where a hung program is stuck.

SIGKILL, SIGSTOP

kill -9, the out-of-memory killer; job control

Cannot be caught, blocked or ignored: os/signal cannot affect them.

SIGPIPE

Write to a closed pipe

Standard output and standard error: the program is terminated. Other descriptors: the write fails with EPIPE.

SIGUSR1, SIGUSR2, SIGWINCH, SIGCHLD and others

Application-defined, terminal resized, child exited

Caught by the runtime and ignored, until Notify asks for them.

SIGTSTP, SIGTTIN, SIGTTOU

Ctrl-Z and terminal job control

The system default, which the shell uses for job control.

SIGSEGV, SIGBUS, SIGFPE

Faults caused by the program itself

Converted into a run-time panic.

On Windows the only signal that exists is os.Interrupt, delivered for Ctrl-C and Ctrl-Break; closing the console window, logging off or shutting down is reported to Notify as syscall.SIGTERM while the process is given a short time to clean up. os.Process.Signal(os.Interrupt) is not implemented on Windows, so code that stops a child that way needs another mechanism there.

signal.Notify

signal.Notify(c, sigs...) relays the listed signals to the channel c, replacing the default behaviour. With no signals listed it relays all of them, which is rarely what you want. The channel must be buffered: Notify never blocks when it sends, so a signal that arrives while nobody is receiving on an unbuffered channel is dropped. A buffer of one is enough for a single "please stop" notification, and go vet has an analyzer (sigchanyzer) for the mistake:

main.go of a separate module, example.com/broken2
package main

import (
	"fmt"
	"os"
	"os/signal"
)

func main() {
	sigs := make(chan os.Signal)
	signal.Notify(sigs, os.Interrupt)
	fmt.Println("waiting for Ctrl-C")
	<-sigs
}
$ go vet .
main.go:11:2: misuse of unbuffered os.Signal channel as argument to signal.Notify

signal.Stop(c) unregisters a channel and guarantees that it receives nothing more; signal.Reset(sigs...) removes every handler for the signals and restores the default; signal.Ignore(sigs...) makes the signals do nothing; and signal.Ignored(sig) reports whether one is currently ignored.

signal.NotifyContext

For the common case (stop on the first Ctrl-C or SIGTERM), signal.NotifyContext(parent, sigs...) (Go 1.16) returns a context that is cancelled when one of the signals arrives, and a stop function. Since Go 1.26 the context is cancelled with a cause that names the signal, so context.Cause(ctx) returns the text interrupt signal received or the text terminated signal received, while ctx.Err() is still context.Canceled. Call stop as soon as signals no longer need to be diverted: it releases the resources and, like signal.Reset, may restore the default behaviour, which is what lets a second Ctrl-C kill a program that is stuck while shutting down.

The program below sends the signals to itself so that its output is deterministic. It shows Notify, NotifyContext with its cause, and what stop does: the final SIGINT terminates the process with the default action, status 130 as seen by the shell, and "not reached" is never printed.

cmd/signals/main.go
package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	self, err := os.FindProcess(os.Getpid())
	if err != nil {
		panic(err)
	}

	// 1. Notify: a buffered channel receives the signals. The buffer must hold
	// at least one value because the package never blocks when it sends.
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	self.Signal(syscall.SIGTERM)
	select {
	case s := <-sigs:
		fmt.Println("Notify received:", s)
	case <-time.After(5 * time.Second):
		fmt.Println("no signal")
	}
	signal.Stop(sigs) // unregister; SIGTERM would now terminate the process again

	// 2. NotifyContext: the context is cancelled by the first signal, and
	// context.Cause says which one.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	self.Signal(os.Interrupt)
	select {
	case <-ctx.Done():
		fmt.Println("NotifyContext: Err =", ctx.Err())
		fmt.Println("NotifyContext: Cause =", context.Cause(ctx))
	case <-time.After(5 * time.Second):
		fmt.Println("no signal")
	}

	// 3. After stop, signals behave as before Notify. A second Ctrl-C now kills
	// the process with the default action instead of being swallowed.
	stop()
	fmt.Println("stopped; sending SIGINT with the default action restored")
	self.Signal(os.Interrupt)
	time.Sleep(5 * time.Second)
	fmt.Println("not reached")
}
$ ./signals
Notify received: terminated
NotifyContext: Err = context canceled
NotifyContext: Cause = interrupt signal received
stopped; sending SIGINT with the default action restored
[exit status 130]

The first line prints terminated, the string form of syscall.SIGTERM (the type is syscall.Signal, and os.Signal is the interface that os.Interrupt and os.Kill also satisfy; they are the only two signal values guaranteed to exist on every platform).

Graceful shutdown of a CLI

Interruption should leave the program’s data consistent. The pattern used by long-running commands is:

  1. create the signal-aware context in main and pass it down;

  2. check ctx.Err() between units of work (between records, between pages), never in the middle of one;

  3. when the context is cancelled, finish or roll back the current unit, flush and close everything, and return instead of exiting from deep inside;

  4. return a distinctive status (conventionally 128 + signal, so 130 for Ctrl-C);

  5. call stop right after the first signal so a second one forces the exit.

cmd/shutdown/main.go
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	cleanup := flag.Duration("cleanup", 0, "simulated time needed to flush before exiting")
	flag.Parse()
	os.Exit(run(*cleanup))
}

func run(cleanup time.Duration) int {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// After the first signal, give the default behaviour back: a second
	// Ctrl-C then terminates the process even if cleanup hangs.
	go func() {
		<-ctx.Done()
		stop()
		fmt.Fprintln(os.Stderr, "shelf: interrupted, finishing the current record (press Ctrl-C again to force quit)")
	}()

	fmt.Println("importing")
	done, err := importRecords(ctx, 20)
	fmt.Printf("imported %d records\n", done)

	time.Sleep(cleanup) // flush files, close connections, ...

	switch {
	case err == nil:
		return 0
	case errors.Is(err, context.Canceled):
		return 130 // 128 + SIGINT, the shell convention for "stopped by Ctrl-C"
	default:
		fmt.Fprintln(os.Stderr, "shelf:", err)
		return 1
	}
}

// importRecords handles one record at a time and checks for cancellation
// between records, never in the middle of one.
func importRecords(ctx context.Context, n int) (int, error) {
	for i := 0; i < n; i++ {
		if err := ctx.Err(); err != nil {
			return i, context.Cause(ctx)
		}
		time.Sleep(100 * time.Millisecond) // the real work
	}
	return n, nil
}
$ ./shutdown & pid=$!; sleep 0.45; kill -INT $pid; wait $pid; echo "status $?"
importing
shelf: interrupted, finishing the current record (press Ctrl-C again to force quit)
imported 5 records
status 130
$ ./shutdown
importing
imported 20 records
status 0

The first run was interrupted after about 0.45 seconds: a handful of records (three in this run; the number depends on timing) had been imported, the message appeared once, and the status was 130. The second run, without a signal, imported all 20 records. In the next transcript the program has a slow cleanup phase (-cleanup 3s), receives a first SIGTERM, starts cleaning up, and receives a second one 0.3 seconds later: because stop had been called after the first, the default action applied and the process died with status 143 at once instead of finishing the three-second cleanup.

$ ./shutdown -cleanup 3s & pid=$!; sleep 0.45; kill -TERM $pid; sleep 0.3; kill -TERM $pid; { wait $pid; } 2>/dev/null; echo "status $?"
importing
shelf: interrupted, finishing the current record (press Ctrl-C again to force quit)
imported 5 records
status 143

The transcripts use SIGTERM for the second signal for a reason worth knowing. A non-interactive shell starts background jobs with SIGINT ignored, and the os/signal documentation says that a program started with SIGINT or SIGHUP ignored, after Notify and later Stop, goes back to ignoring it. So the same experiment with kill -INT from a script never dies on the second signal, while a real Ctrl-C from an interactive terminal does. The behaviour is documented and is the reason scripts should test shutdown with SIGTERM.

The same context is what an HTTP server hands to Server.Shutdown; that service-side version, including the subtlety that the shutdown budget needs a fresh context derived with context.WithoutCancel, is in Context and net/http Clients and Servers.

Running other programs: os/exec

os/exec starts external programs. Unlike C’s system and the shell’s backticks it never invokes a shell: it does not expand $HOME, does not expand wildcards, does not interpret pipes, redirections or ;. The arguments are an exact list. That makes it safe by default (there is no string to inject into), and it means that if a shell feature is needed the caller must ask for a shell explicitly, with the user input passed as a separate argument and never spliced into the command text.

Running a command and reading its result

Method Behaviour

Run()

Starts the command and waits for it to finish.

Output()

Runs it and returns standard output. On failure the error is usually an *exec.ExitError whose Stderr field holds the (possibly truncated) standard error, if Cmd.Stderr was nil.

CombinedOutput()

Runs it and returns standard output and standard error interleaved in one buffer.

Start() and Wait()

Start without waiting; Wait blocks until the process exits and the copying goroutines finish, and releases the resources. After a successful Start, Wait must be called.

StdinPipe(), StdoutPipe(), StderrPipe()

A pipe to or from the child for streaming. Call them before Start, read everything, then call Wait; do not use Run with them because Wait closes the pipes.

String(), Environ()

A human-readable description (debugging only, not for a shell) and the environment the command would use.

The Cmd fields that configure a command:

Field Meaning

Path, Args

The program and its arguments, with Args[0] the program name. exec.Command(name, arg...) fills them in and looks name up on PATH if it has no path separator.

Env

KEY=value strings; nil means "inherit this process’s environment". To add a variable, append to os.Environ(). Duplicates: the last wins.

Dir

Working directory; empty means the current directory. Since Go 1.19 a non-empty Dir with a nil Env also sets PWD for the child on Unix.

Stdin, Stdout, Stderr

Any io.Reader and io.Writer; nil means the null device. An *os.File is connected directly; anything else is copied through a pipe by a goroutine.

ExtraFiles

More inherited file descriptors, numbered from 3. Not supported on Windows.

SysProcAttr

Operating-system specific attributes (process groups, credentials, Windows creation flags).

Cancel, WaitDelay

What happens when the context ends, and how long to wait for stragglers (Go 1.20); see below.

Process, ProcessState

The started process, and after Wait its exit information (ExitCode(), Success(), String()); ExitCode() is -1 if the process was terminated by a signal.

Err

The error from looking up the program, reported by Start/Run (this is where ErrDot shows up).

A Cmd is single-use. Calling Output twice on the same value fails (the transcript below shows the real message), so build a new Cmd for every execution.

cmd/execbasics/main.go
package main

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strings"
)

func main() {
	// Output runs the command and returns its standard output.
	out, err := exec.Command("echo", "hello", "shelf").Output()
	fmt.Printf("Output: %q err=%v\n", out, err)

	// There is no shell: $HOME and * are passed through literally.
	out, _ = exec.Command("echo", "$HOME", "*.go").Output()
	fmt.Printf("no shell: %q\n", out)
	out, _ = exec.Command("sh", "-c", `echo "$1"`, "sh", "an argument; rm -rf / is just text").Output()
	fmt.Printf("sh -c with $1: %q\n", out)

	// A non-zero exit status becomes *exec.ExitError.
	_, err = exec.Command("sh", "-c", "echo oops >&2; exit 3").Output()
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		fmt.Printf("ExitError: %v, code=%d, success=%t, stderr=%q\n", ee, ee.ExitCode(), ee.Success(), ee.Stderr)
	}

	// CombinedOutput interleaves stdout and stderr into one buffer.
	out, err = exec.Command("sh", "-c", "echo to-stdout; echo to-stderr >&2").CombinedOutput()
	fmt.Printf("CombinedOutput: %q err=%v\n", out, err)

	// Stdin: any io.Reader. Env and Dir configure the child.
	cmd := exec.Command("tr", "a-z", "A-Z")
	cmd.Stdin = strings.NewReader("the go programming language\n")
	out, _ = cmd.Output()
	fmt.Printf("stdin: %q\n", out)

	cmd = exec.Command("sh", "-c", `echo "mode=$SHELF_MODE dir=$(basename "$PWD")"`)
	cmd.Env = append(os.Environ(), "SHELF_MODE=readonly")
	cmd.Dir = "/usr"
	out, _ = cmd.Output()
	fmt.Printf("env and dir: %q\n", out)

	// Streaming: StdoutPipe, Start, read, then Wait (never Run with a pipe).
	cmd = exec.Command("sh", "-c", "for i in 1 2 3; do echo record-$i; done")
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		panic(err)
	}
	if err := cmd.Start(); err != nil {
		panic(err)
	}
	sc := bufio.NewScanner(stdout)
	for sc.Scan() {
		fmt.Println("streamed:", sc.Text())
	}
	if err := cmd.Wait(); err != nil {
		panic(err)
	}
	fmt.Println("String():", cmd.String())
	fmt.Println("state:", cmd.ProcessState)
}
$ ./execbasics
Output: "hello shelf\n" err=<nil>
no shell: "$HOME *.go\n"
sh -c with $1: "an argument; rm -rf / is just text\n"
ExitError: exit status 3, code=3, success=false, stderr="oops\n"
CombinedOutput: "to-stdout\nto-stderr\n" err=<nil>
stdin: "THE GO PROGRAMMING LANGUAGE\n"
env and dir: "mode=readonly dir=usr\n"
streamed: record-1
streamed: record-2
streamed: record-3
String(): /bin/sh -c for i in 1 2 3; do echo record-$i; done
state: exit status 0
$ ./execreuse
first:  "once\n" <nil>
second: exec: Stdout already set

Reading the output top to bottom: there is no shell, so $HOME and *.go were printed literally; the same text passed as the $1 of sh -c stays literal text too, which is how to use a shell safely; a non-zero status became an *exec.ExitError carrying the status and the captured standard error; CombinedOutput merged both streams; the Stdin reader fed tr; Env and Dir configured the child; and the streaming version printed each record as it arrived.

Source: os/exec: Cmd

Two classes of error come back from these methods, and callers should tell them apart with errors.As:

  • *exec.ExitError: the program ran and finished unsuccessfully (or was killed). The status is in ExitCode().

  • anything else (*exec.Error, *fs.PathError, a wrapped ErrNotFound, ErrDot, ErrWaitDelay): the program could not be started, or something around it failed.

The process-start primitives below os/exec (os.StartProcess, os.FindProcess, Process.Wait) exist, and on Unix syscall.Exec replaces the current process image outright, as a shelf edit command that hands over to $EDITOR would do; day-to-day code should use os/exec. In Go 1.26 os.Process gained WithHandle, which exposes the underlying process handle (a pidfd on Linux 5.4 and later, a Handle on Windows) for platform-specific code.

LookPath, ErrNotFound and ErrDot

exec.LookPath(file) finds an executable on PATH, and exec.Command calls it for names without a path separator. Two named errors report why a lookup failed:

  • exec.ErrNotFound ("executable file not found in $PATH") when nothing matches;

  • exec.ErrDot ("cannot run executable found relative to current directory") when the match would be an executable in the current directory, because PATH contains . or an empty entry, explicitly or implicitly.

Since Go 1.19, os/exec refuses to run a program found that way. The reason is a classic attack: a program that runs ls from a directory the user does not control would otherwise execute a malicious ./ls placed there. The functions do not return ErrDot itself, so test with errors.Is(err, exec.ErrDot). A program that really wants the current directory says so with a path, "./shelf-tool"; one that insists on accepting relative PATH entries must clear the error deliberately (if errors.Is(cmd.Err, exec.ErrDot) { cmd.Err = nil }). The setting GODEBUG=execerrdot=0 restores the pre-1.19 behaviour for old programs and may be removed in a future release.

cmd/execdot/main.go
package main

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
)

func main() {
	// A tiny executable in a fresh directory, which becomes the working
	// directory, and a PATH that contains "." (an explicit relative entry).
	dir, err := os.MkdirTemp("", "shelf-exec")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(dir)
	script := "#!/bin/sh\necho running the local tool\n"
	if err := os.WriteFile(dir+"/shelf-tool", []byte(script), 0o755); err != nil {
		panic(err)
	}
	if err := os.Chdir(dir); err != nil {
		panic(err)
	}
	os.Setenv("PATH", ".:/usr/bin:/bin")

	// 1. A name found only through ".": LookPath refuses and says why.
	path, err := exec.LookPath("shelf-tool")
	fmt.Printf("LookPath: path=%q\n  err=%v\n  ErrDot=%t NotFound=%t\n",
		path, err, errors.Is(err, exec.ErrDot), errors.Is(err, exec.ErrNotFound))

	// Command stores the same error in cmd.Err and Run reports it.
	cmd := exec.Command("shelf-tool")
	fmt.Printf("cmd.Err is ErrDot: %t\n", errors.Is(cmd.Err, exec.ErrDot))
	fmt.Println("Run:", cmd.Run())

	// 2. Say "./shelf-tool" when the current directory is what you mean.
	out, err := exec.Command("./shelf-tool").Output()
	fmt.Printf("explicit path: %q err=%v\n", out, err)

	// 3. A name that is nowhere on PATH.
	_, err = exec.LookPath("no-such-tool")
	var pe *exec.Error
	fmt.Printf("missing: %v\n  ErrNotFound=%t, *exec.Error=%t\n",
		err, errors.Is(err, exec.ErrNotFound), errors.As(err, &pe))
}
$ ./execdot
LookPath: path="shelf-tool"
  err=exec: "shelf-tool": cannot run executable found relative to current directory
  ErrDot=true NotFound=false
cmd.Err is ErrDot: true
Run: exec: "shelf-tool": cannot run executable found relative to current directory
explicit path: "running the local tool\n" err=<nil>
missing: exec: "no-such-tool": executable file not found in $PATH
  ErrNotFound=true, *exec.Error=true
$ GODEBUG=execerrdot=0 ./execdot | head -3
LookPath: path="shelf-tool"
  err=<nil>
  ErrDot=false NotFound=false

CommandContext, Cancel and WaitDelay

exec.CommandContext(ctx, name, args...) ties the process to a context: when ctx is done before the command finishes, the process is killed. Two Cmd fields, added in Go 1.20, refine this:

  • Cancel func() error is what runs when the context ends. The default (from CommandContext) calls Process.Kill. Replace it to ask politely first, for example by sending SIGTERM or closing a pipe. If the command then exits with success after Cancel was called, Wait still returns an error (the one from Cancel, or the context’s), because the work was cancelled, unless Cancel returns an error wrapping os.ErrProcessDone.

  • WaitDelay time.Duration bounds how long Wait waits after the context ends or the process exits: for a process that ignored the Cancel signal (it is killed when the delay passes) and for output pipes that a grandchild still holds open (they are closed when the delay passes, and Wait returns exec.ErrWaitDelay if everything else succeeded). Zero, the default, waits for end of file on the pipes, however long that takes.

The program demonstrates the four situations: the default kill, a graceful SIGTERM that the child handles, a background grandchild that keeps the output pipe open (with and without WaitDelay), and a child that ignores SIGTERM, which WaitDelay escalates to a kill.

cmd/execctx/main.go
package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"syscall"
	"time"
)

func timed(name string, f func() error) {
	start := time.Now()
	err := f()
	fmt.Printf("%-22s err=%v (about %s)\n", name, err, time.Since(start).Round(100*time.Millisecond))
}

func main() {
	// 1. Default: the context deadline kills the process.
	timed("default (Kill)", func() error {
		ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
		defer cancel()
		return exec.CommandContext(ctx, "sleep", "30").Run()
	})

	// 2. A custom Cancel asks politely with SIGTERM. The script handles it and
	// exits 0, yet Wait still reports the context error, because the command
	// was cancelled.
	timed("Cancel with SIGTERM", func() error {
		ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
		defer cancel()
		cmd := exec.CommandContext(ctx, "sh", "-c", `trap 'echo cleaning up; exit 0' TERM; sleep 30 & wait`)
		cmd.Stdout = os.Stdout
		cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
		return cmd.Run()
	})

	// 3. A child that outlives its parent keeps the output pipe open, so Wait
	// blocks until it exits. WaitDelay bounds that wait.
	slowStart := `sleep 2 & echo started`
	for _, delay := range []time.Duration{0, 300 * time.Millisecond} {
		timed(fmt.Sprintf("WaitDelay=%v", delay), func() error {
			var out bytes.Buffer
			cmd := exec.Command("sh", "-c", slowStart)
			cmd.Stdout = &out // not an *os.File, so Go copies it through a pipe
			cmd.WaitDelay = delay
			err := cmd.Run()
			if errors.Is(err, exec.ErrWaitDelay) {
				fmt.Printf("  output so far: %q\n", out.String())
			}
			return err
		})
	}

	// 4. A process that ignores SIGTERM: Cancel is tried first, and WaitDelay
	// escalates to Kill.
	timed("ignores TERM + delay", func() error {
		ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
		defer cancel()
		cmd := exec.CommandContext(ctx, "sh", "-c", `trap '' TERM; while :; do sleep 1; done`)
		cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
		cmd.WaitDelay = 300 * time.Millisecond
		return cmd.Run()
	})
}
$ ./execctx
default (Kill)         err=signal: killed (about 200ms)
cleaning up
Cancel with SIGTERM    err=context deadline exceeded (about 200ms)
WaitDelay=0s           err=<nil> (about 2s)
  output so far: "started\n"
WaitDelay=300ms        err=exec: WaitDelay expired before I/O complete (about 300ms)
ignores TERM + delay   err=signal: killed (about 500ms)

The second line shows cleaning up, printed by the child’s SIGTERM handler, and an error of context deadline exceeded even though the child exited with status 0: the command was cancelled. The third and fourth results are the WaitDelay pair: with no delay, Wait blocked for two seconds until the orphaned sleep released the pipe and returned nil; with a 300 ms delay it returned ErrWaitDelay after 0.3 seconds, with the output received so far intact. The last line killed a process that ignored the polite request, 300 ms after it was made.

The context side of this story, including how it combines with errgroup, is in Context. Sending signals to a whole process group (so that grandchildren are stopped too) needs SysProcAttr (Setpgid on Unix) and a negative process id with syscall.Kill; that is Unix-only code and outside the portable subset covered here.

Building the shelf CLI

The pieces come together in cmd/shelf, the command-line program of the running scenario. It keeps the catalogue in a JSON file and offers six commands:

Command Behaviour

shelf add -isbn ISBN -title TITLE [-author NAME]...

Add or replace a book; the ISBN is validated by catalog.ParseISBN.

shelf search [-json] [-limit N] TEXT...

Case-insensitive match on title or author; a table by default, JSON with -json.

shelf lend -member ID ISBN, shelf return ISBN

Lend a book to a member, or take it back; both refuse impossible requests.

shelf import [FILE|-], shelf export [-o FILE]

Read a JSON array of books from a file or standard input; write all books as JSON to standard output or a file.

global -data FILE, -version

Library file (default $SHELF_DATA, then ./shelf.json); print the version.

The design follows the rules of the previous sections. Streams, environment and signals enter through main, which is nothing but wiring; run parses, dispatches and translates errors into one of five statuses; and each command receives the context.Context, the streams and the path of the library file, and returns an error:

flowchart TD M["main: NotifyContext, os.Stdin, os.Stdout, os.Stderr, os.Getenv"] --> R["run(ctx, args, app) int"] R --> P{"parse global flags and pick the command"} P -- "bad flag or unknown command" --> U["status 2 (usage)"] P -- "-h or -help" --> H["status 0"] P -- "-version" --> V["print version, status 0"] P -- "ok" --> C["the command runs"] C --> E{"what did it return?"} E -- "nil" --> OK["status 0"] E -- "errUsage (already reported)" --> U E -- "context.Canceled" --> I["status 130"] E -- "errNotFound, errOnLoan, errNotOnLoan" --> N["status 3"] E -- "any other error" --> F["status 1"]
Status Meaning in shelf

0

Success, including -h and -version, and a search with no matches (a message goes to standard error, not an error).

1

Failure: unreadable or corrupt library file, bad JSON, I/O error.

2

Usage: unknown command, bad or missing flag, malformed ISBN, wrong number of arguments.

3

The catalogue’s rules refused the request: no such book, already on loan, not on loan.

130

Interrupted by SIGINT or SIGTERM between records of an import.

main.go

main creates the signal context, builds the app value from the real streams and os.Getenv, calls run and exits. It calls stop before os.Exit because os.Exit skips deferred calls; the defer stop() documents intent and covers a panic. The version variable is set at link time with -ldflags "-X main.version=v1.4.0" (see The go Command, Builds and Cross-Compilation), and versionString falls back to the module version recorded by go install pkg@version that runtime/debug.ReadBuildInfo exposes.

cmd/shelf/main.go
// Command shelf manages a small library catalogue stored in a JSON file.
package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"
)

// version is set at link time: go build -ldflags "-X main.version=v1.4.0".
var version = "dev"

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	a := &app{
		stdin:  os.Stdin,
		stdout: os.Stdout,
		stderr: os.Stderr,
		getenv: os.Getenv,
	}
	code := run(ctx, os.Args[1:], a)
	stop() // os.Exit skips deferred calls, so release the signal handlers first
	os.Exit(code)
}

store.go

The library file is a JSON document, loaded whole and saved atomically: write a temporary file in the same directory and os.Rename it over the target, so that a crash or a full disk never leaves a half-written catalogue. A missing file is an empty library (errors.Is(err, fs.ErrNotExist)). The domain errors are sentinels that run maps to status 3; they are wrapped with %w so the message says which ISBN.

cmd/shelf/store.go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"slices"

	"example.com/shelf/catalog"
)

var (
	errNotFound  = errors.New("no such book")
	errOnLoan    = errors.New("book is already on loan")
	errNotOnLoan = errors.New("book is not on loan")
)

// library is the document stored on disk.
type library struct {
	Books []catalog.Book `json:"books"`
	Loans []catalog.Loan `json:"loans"`
}

// load reads the library file; a missing file is an empty library.
func load(path string) (*library, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		return &library{}, nil
	}
	if err != nil {
		return nil, err
	}
	var lib library
	if err := json.Unmarshal(data, &lib); err != nil {
		return nil, fmt.Errorf("%s: %w", path, err)
	}
	return &lib, nil
}

// save writes the library atomically: a temporary file in the same directory,
// then a rename, so a crash never leaves a half-written catalogue.
func (l *library) save(path string) error {
	data, err := json.MarshalIndent(l, "", "  ")
	if err != nil {
		return err
	}
	tmp, err := os.CreateTemp(filepath.Dir(path), ".shelf-*.tmp")
	if err != nil {
		return err
	}
	defer os.Remove(tmp.Name()) // harmless after a successful rename
	if _, err := tmp.Write(append(data, '\n')); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmp.Name(), path)
}

func (l *library) find(isbn catalog.ISBN) (int, bool) {
	return slices.BinarySearchFunc(l.Books, isbn, func(b catalog.Book, id catalog.ISBN) int {
		switch {
		case b.ISBN < id:
			return -1
		case b.ISBN > id:
			return 1
		}
		return 0
	})
}

// put inserts or replaces a book, keeping Books sorted by ISBN.
func (l *library) put(b catalog.Book) {
	i, ok := l.find(b.ISBN)
	if ok {
		l.Books[i] = b
		return
	}
	l.Books = slices.Insert(l.Books, i, b)
}

func (l *library) loanIndex(isbn catalog.ISBN) int {
	return slices.IndexFunc(l.Loans, func(ln catalog.Loan) bool { return ln.Book == isbn })
}

func (l *library) lend(isbn catalog.ISBN, member string) error {
	if _, ok := l.find(isbn); !ok {
		return fmt.Errorf("%s: %w", isbn, errNotFound)
	}
	if l.loanIndex(isbn) >= 0 {
		return fmt.Errorf("%s: %w", isbn, errOnLoan)
	}
	l.Loans = append(l.Loans, catalog.Loan{Book: isbn, Member: member, State: catalog.OnLoan})
	return nil
}

func (l *library) giveBack(isbn catalog.ISBN) error {
	i := l.loanIndex(isbn)
	if i < 0 {
		return fmt.Errorf("%s: %w", isbn, errNotOnLoan)
	}
	l.Loans = slices.Delete(l.Loans, i, i+1)
	return nil
}

app.go

app.go holds run, the shared flag helpers and the six commands. Points to notice, each tied to a section above:

  • the global FlagSet is parsed first, so -data and -version must precede the command, and Args()[0] names it;

  • newFlags and parse give every command a ContinueOnError set whose output is the injected standard error, and turn any parse failure into errUsage so that the message is printed once;

  • the data path is resolved flag, then $SHELF_DATA through the injected getenv, then shelf.json;

  • search writes results to standard output, no matches to standard error, and an empty JSON result as [] (a nil slice would be encoded as null);

  • import accepts a file or -, checks ctx.Err() between records and returns the context error, which run turns into 130;

  • export writes to standard output or a file, and the deferred Close of a file opened for writing reports its error through the named result, because that is the last chance to learn that the data did not reach the disk.

cmd/shelf/app.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"runtime/debug"
	"strings"
	"text/tabwriter"

	"example.com/shelf/catalog"
	"example.com/shelf/internal/textutil"
)

// Exit statuses. 0 and 1 are universal; 2 is the convention for usage errors
// (it is also what the flag package and the Go runtime use).
const (
	exitOK          = 0
	exitFailure     = 1
	exitUsage       = 2
	exitRefused     = 3 // the catalogue rules said no: unknown book, already on loan
	exitInterrupted = 130
)

// app holds everything a command touches outside the process: tests replace it.
type app struct {
	stdin          io.Reader
	stdout, stderr io.Writer
	getenv         func(string) string
}

// errUsage means the problem has already been reported to the user.
var errUsage = errors.New("usage error")

type command struct {
	summary string
	run     func(ctx context.Context, a *app, lib string, args []string) error
}

var commands = map[string]command{
	"add":    {"add or replace a book", cmdAdd},
	"search": {"search by title or author", cmdSearch},
	"lend":   {"lend a book to a member", cmdLend},
	"return": {"return a book", cmdReturn},
	"import": {"import books from JSON (file or -)", cmdImport},
	"export": {"export books as JSON", cmdExport},
}

func run(ctx context.Context, args []string, a *app) int {
	global := flag.NewFlagSet("shelf", flag.ContinueOnError)
	global.SetOutput(a.stderr)
	data := global.String("data", "", "path of the library `file` (default $SHELF_DATA, then ./shelf.json)")
	showVersion := global.Bool("version", false, "print the version and exit")
	global.Usage = func() { usage(global, a.stderr) }

	if err := global.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return exitOK
		}
		return exitUsage
	}
	if *showVersion {
		fmt.Fprintln(a.stdout, versionString())
		return exitOK
	}

	rest := global.Args()
	if len(rest) == 0 {
		global.Usage()
		return exitUsage
	}
	cmd, ok := commands[rest[0]]
	if !ok {
		fmt.Fprintf(a.stderr, "shelf: unknown command %q\n", rest[0])
		global.Usage()
		return exitUsage
	}

	// Precedence: flag, then environment, then default.
	path := *data
	if path == "" {
		path = a.getenv("SHELF_DATA")
	}
	if path == "" {
		path = "shelf.json"
	}

	err := cmd.run(ctx, a, path, rest[1:])
	switch {
	case err == nil, errors.Is(err, flag.ErrHelp):
		return exitOK
	case errors.Is(err, errUsage):
		return exitUsage
	case errors.Is(err, context.Canceled):
		fmt.Fprintf(a.stderr, "shelf %s: interrupted\n", rest[0])
		return exitInterrupted
	case errors.Is(err, errNotFound), errors.Is(err, errOnLoan), errors.Is(err, errNotOnLoan):
		fmt.Fprintf(a.stderr, "shelf %s: %v\n", rest[0], err)
		return exitRefused
	default:
		fmt.Fprintf(a.stderr, "shelf %s: %v\n", rest[0], err)
		return exitFailure
	}
}

func usage(global *flag.FlagSet, w io.Writer) {
	fmt.Fprintln(w, "usage: shelf [-data file] <command> [flags] [args]")
	fmt.Fprintln(w, "\ncommands:")
	for _, name := range []string{"add", "search", "lend", "return", "import", "export"} {
		fmt.Fprintf(w, "  %-8s %s\n", name, commands[name].summary)
	}
	fmt.Fprintln(w, "\nglobal flags:")
	global.SetOutput(w)
	global.PrintDefaults()
}

func versionString() string {
	v := version
	if bi, ok := debug.ReadBuildInfo(); ok && v == "dev" && bi.Main.Version != "" && bi.Main.Version != "(devel)" {
		v = bi.Main.Version
	}
	return "shelf " + v
}

// newFlags returns a FlagSet whose messages go to stderr and whose parse
// failures are reported once, as errUsage.
func newFlags(a *app, name string) *flag.FlagSet {
	fs := flag.NewFlagSet("shelf "+name, flag.ContinueOnError)
	fs.SetOutput(a.stderr)
	return fs
}

func parse(fs *flag.FlagSet, args []string) error {
	if err := fs.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return err
		}
		return errUsage
	}
	return nil
}

func usageErr(a *app, format string, v ...any) error {
	fmt.Fprintf(a.stderr, "shelf: "+format+"\n", v...)
	return errUsage
}

// authors is a repeatable -author flag.
type authors []string

func (s *authors) String() string     { return strings.Join(*s, ", ") }
func (s *authors) Set(v string) error { *s = append(*s, v); return nil }

func cmdAdd(_ context.Context, a *app, path string, args []string) error {
	fs := newFlags(a, "add")
	isbn := fs.String("isbn", "", "ISBN-13 of the book (required)")
	title := fs.String("title", "", "title (required)")
	var by authors
	fs.Var(&by, "author", "author `name` (repeatable)")
	if err := parse(fs, args); err != nil {
		return err
	}
	id, err := catalog.ParseISBN(*isbn)
	if err != nil {
		return usageErr(a, "%v", err)
	}
	if strings.TrimSpace(*title) == "" {
		return usageErr(a, "add: -title is required")
	}
	book := catalog.Book{ISBN: id, Title: strings.TrimSpace(*title)}
	for _, n := range by {
		book.Authors = append(book.Authors, catalog.Author{Name: n})
	}

	lib, err := load(path)
	if err != nil {
		return err
	}
	lib.put(book)
	if err := lib.save(path); err != nil {
		return err
	}
	fmt.Fprintf(a.stdout, "added %s %q\n", book.ISBN, book.Title)
	return nil
}

func cmdSearch(_ context.Context, a *app, path string, args []string) error {
	fs := newFlags(a, "search")
	asJSON := fs.Bool("json", false, "print the matches as JSON")
	limit := fs.Int("limit", 20, "maximum number of results")
	if err := parse(fs, args); err != nil {
		return err
	}
	query := textutil.Normalize(strings.Join(fs.Args(), " "))

	lib, err := load(path)
	if err != nil {
		return err
	}
	found := []catalog.Book{} // not nil: a nil slice would encode as null
	for _, b := range lib.Books {
		if matches(b, query) {
			found = append(found, b)
			if len(found) == *limit {
				break
			}
		}
	}

	if *asJSON {
		enc := json.NewEncoder(a.stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(found)
	}
	tw := tabwriter.NewWriter(a.stdout, 0, 8, 2, ' ', 0)
	for _, b := range found {
		status := catalog.Available
		if lib.loanIndex(b.ISBN) >= 0 {
			status = catalog.OnLoan
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\n", b.ISBN, b.Title, status)
	}
	if err := tw.Flush(); err != nil {
		return err
	}
	if len(found) == 0 {
		fmt.Fprintln(a.stderr, "shelf: no matches") // diagnostics go to stderr, data to stdout
	}
	return nil
}

func matches(b catalog.Book, query string) bool {
	if strings.Contains(textutil.Normalize(b.Title), query) {
		return true
	}
	for _, au := range b.Authors {
		if strings.Contains(textutil.Normalize(au.Name), query) {
			return true
		}
	}
	return false
}

func cmdLend(_ context.Context, a *app, path string, args []string) error {
	fs := newFlags(a, "lend")
	member := fs.String("member", "", "member `id` (required)")
	if err := parse(fs, args); err != nil {
		return err
	}
	if *member == "" || fs.NArg() != 1 {
		return usageErr(a, "lend needs -member and exactly one ISBN")
	}
	isbn, err := catalog.ParseISBN(fs.Arg(0))
	if err != nil {
		return usageErr(a, "%v", err)
	}
	lib, err := load(path)
	if err != nil {
		return err
	}
	if err := lib.lend(isbn, *member); err != nil {
		return err
	}
	if err := lib.save(path); err != nil {
		return err
	}
	fmt.Fprintf(a.stdout, "lent %s to %s\n", isbn, *member)
	return nil
}

func cmdReturn(_ context.Context, a *app, path string, args []string) error {
	fs := newFlags(a, "return")
	if err := parse(fs, args); err != nil {
		return err
	}
	if fs.NArg() != 1 {
		return usageErr(a, "return needs exactly one ISBN")
	}
	isbn, err := catalog.ParseISBN(fs.Arg(0))
	if err != nil {
		return usageErr(a, "%v", err)
	}
	lib, err := load(path)
	if err != nil {
		return err
	}
	if err := lib.giveBack(isbn); err != nil {
		return err
	}
	if err := lib.save(path); err != nil {
		return err
	}
	fmt.Fprintf(a.stdout, "returned %s\n", isbn)
	return nil
}

// cmdImport reads a JSON array of books from a file, or from standard input
// when the argument is "-" or missing and input is piped in.
func cmdImport(ctx context.Context, a *app, path string, args []string) error {
	fs := newFlags(a, "import")
	if err := parse(fs, args); err != nil {
		return err
	}
	var in io.Reader = a.stdin
	switch {
	case fs.NArg() > 1:
		return usageErr(a, "import takes at most one file (or -)")
	case fs.NArg() == 1 && fs.Arg(0) != "-":
		f, err := os.Open(fs.Arg(0))
		if err != nil {
			return err
		}
		defer f.Close()
		in = f
	}

	var books []catalog.Book
	if err := json.NewDecoder(in).Decode(&books); err != nil {
		return fmt.Errorf("reading JSON: %w", err)
	}
	lib, err := load(path)
	if err != nil {
		return err
	}
	for i, b := range books {
		if err := ctx.Err(); err != nil { // stop between records, never in the middle of one
			return err
		}
		if _, err := catalog.ParseISBN(string(b.ISBN)); err != nil {
			return fmt.Errorf("record %d: %w", i+1, err)
		}
		lib.put(b)
	}
	if err := lib.save(path); err != nil {
		return err
	}
	fmt.Fprintf(a.stdout, "imported %d books\n", len(books))
	return nil
}

func cmdExport(_ context.Context, a *app, path string, args []string) (err error) {
	fs := newFlags(a, "export")
	out := fs.String("o", "-", "write to `file` instead of standard output")
	if err := parse(fs, args); err != nil {
		return err
	}
	lib, err := load(path)
	if err != nil {
		return err
	}

	w := a.stdout
	if *out != "-" {
		f, err := os.Create(*out)
		if err != nil {
			return err
		}
		defer func() {
			// The Close error of a file opened for writing is the last chance
			// to learn that the data did not reach the disk.
			if cerr := f.Close(); cerr != nil && err == nil {
				err = cerr
			}
		}()
		w = f
	}
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(lib.Books)
}

Using it

All of the following transcripts are real runs of the built program (built with go build -trimpath -ldflags "-X main.version=v1.4.0" ./cmd/shelf) in an empty directory. The first adds books, shows the usage status for a malformed ISBN, and searches.

$ shelf -version
shelf v1.4.0
$ shelf add -isbn 978-0-13-419044-0 -title "The Go Programming Language" -author "Alan Donovan" -author "Brian Kernighan"
added 9780134190440 "The Go Programming Language"
$ shelf add -isbn 978-0-13-468599-1 -title "The Pragmatic Programmer" -author "David Thomas"
added 9780134685991 "The Pragmatic Programmer"
$ shelf add -isbn 978-1-4919-3-8 -title "Bad ISBN"
shelf: catalog: invalid ISBN: "978-1-4919-3-8"
[exit status 2]
$ shelf search go
9780134190440  The Go Programming Language  available
$ shelf search -json donovan
[
  {
    "ISBN": "9780134190440",
    "Title": "The Go Programming Language",
    "Authors": [
      {
        "Name": "Alan Donovan"
      },
      {
        "Name": "Brian Kernighan"
      }
    ]
  }
]
$ shelf search zzz; echo "status $?"; shelf search -json zzz
shelf: no matches
status 0
[]

A search with no matches exits with 0 and prints shelf: no matches on standard error only, and with -json it prints []. Next, lending, the refusals with status 3, and the usage errors:

$ shelf lend -member ana 978-0-13-419044-0
lent 9780134190440 to ana
$ shelf lend -member bob 978-0-13-419044-0
shelf lend: 9780134190440: book is already on loan
[exit status 3]
$ shelf search programming
9780134190440  The Go Programming Language  on loan
$ shelf return 978-0-13-419044-0
returned 9780134190440
$ shelf return 978-0-13-419044-0
shelf return: 9780134190440: book is not on loan
[exit status 3]
$ shelf lend 978-0-13-419044-0
shelf: lend needs -member and exactly one ISBN
[exit status 2]
$ shelf frobnicate
shelf: unknown command "frobnicate"
usage: shelf [-data file] <command> [flags] [args]

commands:
  add      add or replace a book
  search   search by title or author
  lend     lend a book to a member
  return   return a book
  import   import books from JSON (file or -)
  export   export books as JSON

global flags:
  -data file
    	path of the library file (default $SHELF_DATA, then ./shelf.json)
  -version
    	print the version and exit
[exit status 2]

Flag errors come from the flag package; the program printed each message once:

$ shelf search -limit
flag needs an argument: -limit
Usage of shelf search:
  -json
    	print the matches as JSON
  -limit int
    	maximum number of results (default 20)
[exit status 2]
$ shelf -h
usage: shelf [-data file] <command> [flags] [args]

commands:
  add      add or replace a book
  search   search by title or author
  lend     lend a book to a member
  return   return a book
  import   import books from JSON (file or -)
  export   export books as JSON

global flags:
  -data file
    	path of the library file (default $SHELF_DATA, then ./shelf.json)
  -version
    	print the version and exit

Finally the data commands. The pipeline shelf export | shelf -data copy.json import - copies a catalogue through standard output and standard input; the failing imports show the exact messages and status 1, and the last two commands show the environment variable, and a write failure in a directory that does not exist (the temporary file cannot be created, so the original file is never touched):

$ shelf export -o books.json && head -8 books.json
[
  {
    "ISBN": "9780134190440",
    "Title": "The Go Programming Language",
    "Authors": [
      {
        "Name": "Alan Donovan"
      },
$ shelf export | shelf -data copy.json import -
imported 2 books
$ echo "[{\"ISBN\":\"123\"}]" | shelf -data copy.json import
shelf import: record 1: catalog: invalid ISBN: "123"
[exit status 1]
$ echo "not json" | shelf -data copy.json import
shelf import: reading JSON: invalid character 'o' in literal null (expecting 'u')
[exit status 1]
$ shelf import missing.json
shelf import: open missing.json: no such file or directory
[exit status 1]
$ SHELF_DATA=copy.json shelf search kernighan
9780134190440  The Go Programming Language  available
$ shelf -data /no/such/dir/x.json add -isbn 978-0-13-419044-0 -title t
shelf add: open /no/such/dir/.shelf-1046778161.tmp: no such file or directory
[exit status 1]
$ ls -A
books.json
copy.json
shelf.json

Testing run

Because run takes its streams and environment as parameters, tests need no subprocess, no real terminal and no global state: they create buffers, a fake getenv and, for interruption, an already-cancelled context. The table of exit codes is the contract; the second test checks that a flag beats the environment; the third imports from standard input, interrupts an import and feeds a bad record.

cmd/shelf/app_test.go
package main

import (
	"bytes"
	"context"
	"path/filepath"
	"strings"
	"testing"
)

// runShelf runs the CLI in-process with fake streams and environment.
func runShelf(t *testing.T, ctx context.Context, stdin string, env map[string]string, args ...string) (code int, stdout, stderr string) {
	t.Helper()
	var out, errb bytes.Buffer
	a := &app{
		stdin:  strings.NewReader(stdin),
		stdout: &out,
		stderr: &errb,
		getenv: func(k string) string { return env[k] },
	}
	code = run(ctx, args, a)
	return code, out.String(), errb.String()
}

func TestExitCodes(t *testing.T) {
	data := filepath.Join(t.TempDir(), "lib.json")
	const isbn = "978-0-13-419044-0"

	tests := []struct {
		name     string
		args     []string
		wantCode int
		wantErr  string // substring of stderr
	}{
		{"add", []string{"-data", data, "add", "-isbn", isbn, "-title", "TGPL"}, exitOK, ""},
		{"lend", []string{"-data", data, "lend", "-member", "ana", isbn}, exitOK, ""},
		{"lend twice", []string{"-data", data, "lend", "-member", "bob", isbn}, exitRefused, "already on loan"},
		{"bad isbn", []string{"-data", data, "lend", "-member", "ana", "123"}, exitUsage, "invalid ISBN"},
		{"missing member", []string{"-data", data, "lend", isbn}, exitUsage, "lend needs"},
		{"unknown command", []string{"-data", data, "frob"}, exitUsage, "unknown command"},
		{"unknown flag", []string{"-nope"}, exitUsage, "not defined"},
		{"no command", nil, exitUsage, "usage: shelf"},
		{"help", []string{"-h"}, exitOK, "usage: shelf"},
		{"return", []string{"-data", data, "return", isbn}, exitOK, ""},
		{"return twice", []string{"-data", data, "return", isbn}, exitRefused, "not on loan"},
	}
	for _, tc := range tests {
		code, _, stderr := runShelf(t, t.Context(), "", nil, tc.args...)
		if code != tc.wantCode {
			t.Errorf("%s: exit code = %d, want %d (stderr %q)", tc.name, code, tc.wantCode, stderr)
		}
		if !strings.Contains(stderr, tc.wantErr) {
			t.Errorf("%s: stderr = %q, want it to contain %q", tc.name, stderr, tc.wantErr)
		}
	}
}

func TestDataPathPrecedence(t *testing.T) {
	dir := t.TempDir()
	fromEnv := filepath.Join(dir, "env.json")
	fromFlag := filepath.Join(dir, "flag.json")
	const isbn = "978-0-13-419044-0"
	env := map[string]string{"SHELF_DATA": fromEnv}

	runShelf(t, t.Context(), "", env, "add", "-isbn", isbn, "-title", "In env file")
	runShelf(t, t.Context(), "", env, "-data", fromFlag, "add", "-isbn", isbn, "-title", "In flag file")

	_, out, _ := runShelf(t, t.Context(), "", nil, "-data", fromEnv, "search", "env")
	if !strings.Contains(out, "In env file") {
		t.Errorf("env file search = %q", out)
	}
	_, out, _ = runShelf(t, t.Context(), "", nil, "-data", fromFlag, "search", "flag")
	if !strings.Contains(out, "In flag file") {
		t.Errorf("flag file search = %q", out)
	}
}

func TestImportFromStdinAndInterrupt(t *testing.T) {
	data := filepath.Join(t.TempDir(), "lib.json")
	input := `[{"ISBN":"9780134190440","Title":"TGPL"}]`

	code, out, _ := runShelf(t, t.Context(), input, nil, "-data", data, "import", "-")
	if code != exitOK || !strings.Contains(out, "imported 1 books") {
		t.Fatalf("import: code=%d out=%q", code, out)
	}

	// A context that is already cancelled models Ctrl-C arriving mid-import.
	ctx, cancel := context.WithCancel(t.Context())
	cancel()
	code, _, stderr := runShelf(t, ctx, input, nil, "-data", data, "import", "-")
	if code != exitInterrupted {
		t.Errorf("interrupted import: code=%d stderr=%q", code, stderr)
	}

	code, _, stderr = runShelf(t, t.Context(), `[{"ISBN":"123"}]`, nil, "-data", data, "import")
	if code != exitFailure || !strings.Contains(stderr, "record 1") {
		t.Errorf("bad record: code=%d stderr=%q", code, stderr)
	}
}
=== RUN   TestExitCodes
--- PASS: TestExitCodes (0.00s)
=== RUN   TestDataPathPrecedence
--- PASS: TestDataPathPrecedence (0.00s)
=== RUN   TestImportFromStdinAndInterrupt
--- PASS: TestImportFromStdinAndInterrupt (0.00s)
PASS
ok  	example.com/shelf/cmd/shelf	0.175s

With the race detector and coverage the package reports:

ok  	example.com/shelf/cmd/shelf	1.206s	coverage: 65.9% of statements

For end-to-end tests of the built binary (arguments, files, pipes, exit statuses in one script), see the testscript approach in Testing CLIs. The program also cross-compiles unchanged: it was built for GOOS=linux GOARCH=arm64 and GOOS=windows GOARCH=amd64 (compile only) because it uses only portable APIs (os.Interrupt and syscall.SIGTERM exist on both); packaging, signing and release channels (GoReleaser, Homebrew, go install) are in Packaging & Distribution.

Beyond the standard library

flag is a good tool until a program needs one of the following: nested command trees with per-command help, short and long aliases, grouped short options, flags interleaved with arguments, generated shell completion or generated man pages. At that point the usual answer in Go is Cobra, and for full-screen terminal interfaces Bubble Tea. Both are covered, with their APIs, with a running example and with notes for AI-agent callers, in Go & Rust CLI Frameworks; the choice between a command-line program and a terminal interface is in TUIs vs. CLIs. What stays the same whichever library parses the arguments is everything on this page after the flag sections: the run function that returns an exit status, the separation of standard output and standard error, the environment precedence, the signal-aware context and the care taken with child processes.

The standard library is enough when Reach for a framework when

A few flags and at most a handful of subcommands

The command tree is deep or grows often

The audience is the team that wrote it

End users expect polished --help, completion and man pages

Dependency count matters (one static binary, nothing to audit)

Consistent flag conventions across many commands matter more

Output is plain text or JSON

An interactive terminal interface is the product

The related design pages of the CLI section, each worth reading before shipping a tool: CLI Design Fundamentals, Self-Describing Help, Safety & Idempotency and Agent Readiness Checklist.

Pitfalls

Pitfall What to do instead

os.Exit or log.Fatal in the middle of a function that has deferred calls

Return an error to main, exit there, after run has returned.

Indexing os.Args[1] without checking

Use flag.NArg() after parsing, or check len(os.Args).

Assuming flags can follow positional arguments

Parsing stops at the first non-flag; document it or re-parse the rest.

-json false to turn a boolean off

-json=false.

Printing the error again after a FlagSet with ContinueOnError already printed it

Map the failure to a status silently.

Exit statuses outside 0 to 255, or 256 meaning failure

Stay in 0 to 125; os.Exit(256) exits with 0 on Unix.

Everything on standard output, including errors and prompts

Data on standard output, everything else on standard error.

A bufio.Writer on standard output without a checked Flush

if err := w.Flush(); err != nil, and decide what a broken pipe means.

bufio.Scanner on input with long lines

sc.Buffer(...) with a larger limit, or bufio.Reader; always check sc.Err().

Detecting a terminal with ModeCharDevice and trusting it

It is true for /dev/null too; use a real IsTerminal check from golang.org/x/term when it matters.

os.Getenv where an empty value is meaningful

os.LookupEnv.

Secrets in flags or logged environments

A file, a credential helper or standard input; never log os.Environ().

An unbuffered channel passed to signal.Notify

make(chan os.Signal, 1); go vet reports it.

Forgetting to call stop from NotifyContext

defer stop(), plus an early stop() after the first signal so a second one kills.

Treating context.Canceled after Ctrl-C as an ordinary failure

Return 130 and say "interrupted", not a stack of wrapped errors.

Building a shell command line with user input and sh -c

Pass every user value as a separate argument of exec.Command; if a shell is needed, pass values as $1, $2.

Reusing a Cmd

A new Cmd for each run: exec: Stdout already set.

cmd.Run() after StdoutPipe()

Start, read, then Wait.

CommandContext on a command whose children outlive it

Set WaitDelay; use a process group when grandchildren must stop too.

A relative PATH entry and exec.ErrDot

Use an explicit ./name or an absolute path; do not blanket-ignore ErrDot.

Reading configuration in library code

Take parameters; only main reads flags, environment and files.

Where the books are outdated

  • Vivien, Learning Go Programming, chapter 6 introduces os.Args and the flag package for Go 1.6 and 1.7. The basics it teaches (the flag syntax, flag.Parse, Args, the pointer and Var constructors) are unchanged and still correct. It predates flag.Func (Go 1.16), flag.TextVar (Go 1.19) and flag.BoolFunc (Go 1.21), the change that makes -h exit with status 0 (Go 1.15), the panic on invalid flag names (Go 1.17). The chapter’s workflow is GOPATH based: building binaries and fetching remote packages are done today with modules, go build, and go install pkg@version; see Modules and Dependency Management and The go Command, Builds and Cross-Compilation.

  • Vivien, chapter 10 (standard streams, fmt and bufio scanning, files) uses ioutil.ReadAll and similar helpers; the io/ioutil package has been deprecated since Go 1.16 and its functions live in io (io.ReadAll) and os (os.ReadFile, os.WriteFile, os.CreateTemp).

  • Neither book covers signals and graceful shutdown (os/signal, and signal.NotifyContext since Go 1.16, with a cause since Go 1.26), os/exec beyond the basics (Cmd.Cancel and Cmd.WaitDelay in Go 1.20, ErrDot in Go 1.19, which changed how PATH is searched), the "tiny main, testable `run`" structure, or exit-status design. The official documentation linked below supplies them.

  • Bodner, Learning Go (1st edition) has no command-line chapter. Its context chapter (chapter 12) predates the cause that signal.NotifyContext attaches (Go 1.26) and exec.Cmd.Cancel and WaitDelay (Go 1.20), both described above.

Version notes

Version Change relevant to this page

Go 1.15

-h/-help on a FlagSet with ExitOnError exits 0 instead of 2. A returning TestMain no longer needs os.Exit.

Go 1.16

flag.Func; signal.NotifyContext; io.ReadAll and os.ReadFile replace the io/ioutil functions.

Go 1.17

flag panics when a flag is defined with an invalid name (a leading - or an =).

Go 1.19

flag.TextVar; exec.ErrDot and the refusal to run executables found through the current directory; Cmd.Environ; Cmd.Dir sets PWD.

Go 1.20

Cmd.Cancel, Cmd.WaitDelay and exec.ErrWaitDelay.

Go 1.21

flag.BoolFunc; a flag defined after Set was called for it panics.

Go 1.26

signal.NotifyContext cancels with a cause naming the signal; os.Process.WithHandle.

Go 1.27

No API additions to flag, os/exec or os/signal.

Go 1.28 (not released; expected February 2027)

A draft of the release notes mentions iterator methods for flag. Do not rely on it yet.

References