A Command-Line Tool With Nothing But the Standard Library

Building a real CLI with flag and os: parsing flags and positional args, the auto-generated usage, exit codes with os.Exit and the deferred-function trap it springs, writing errors to stderr, and subcommands with flag.NewFlagSet. Compiled and run against Go 1.26.5.

A command-line tool is often the first real thing you ship in a new language, and Go makes it a good first thing to ship: one static binary, no runtime to install on the target machine, and a standard library that already contains everything a well-behaved CLI needs. This chapter builds one out of flag and os, and it spends as much time on the conventions of being a good command-line citizen (exit codes, the right output stream, a usage message) as on the parsing, because those conventions are what separate a script from a tool. Everything here was built and run against Go 1.26.5, and the exit codes shown are the ones actually observed.

Flags: named options with defaults for free

The flag package parses named command-line options. You declare each flag with a name, a default value, and a help string; the call returns a pointer to where the parsed value will land, and you read it after calling flag.Parse:

package main

import (
	"flag"
	"fmt"
)

func main() {
	workers := flag.Int("workers", 1, "number of concurrent workers")
	name := flag.String("name", "job", "name of the run")
	verbose := flag.Bool("verbose", false, "enable verbose logging")

	flag.Parse()

	fmt.Printf("workers=%d name=%q verbose=%v\n", *workers, *name, *verbose)
	fmt.Printf("positional args: %v\n", flag.Args())
	fmt.Printf("count of positionals: %d\n", flag.NArg())
}

The pointer indirection is the one thing that surprises people: flag.Int does not return an int, it returns an *int whose value is not filled in until flag.Parse has run. Read *workers before Parse and you get the default. Read it after and you get whatever the user passed. Build it and run it with real arguments:

$ ./cli -workers=4 -name=bookshop -verbose report.csv archive.csv
workers=4 name="bookshop" verbose=true
positional args: [report.csv archive.csv]
count of positionals: 2

Everything the parser did not recognize as a flag is a positional argument, available as a slice from flag.Args() with a count from flag.NArg(). Here report.csv and archive.csv are the positionals: the files the tool would operate on. Flags can be written -name=bookshop or -name bookshop, and a bool flag is set simply by naming it (-verbose), which is the convention users expect.

Run it with no arguments and every flag falls back to the default you declared:

$ ./cli
workers=1 name="job" verbose=false
positional args: []
count of positionals: 0

And you get a usage message with no extra work. The flag package wires up -h (and --help) to print each flag, its type, its help string, and its default:

$ ./cli -h
Usage of ./cli:
  -name string
    	name of the run (default "job")
  -verbose
    	enable verbose logging
  -workers int
    	number of concurrent workers (default 1)

That table is generated from the same declarations that parse the flags, so it can never drift out of sync with what the tool actually accepts. For raw access to the argument vector, including the program name itself, os.Args is always there (os.Args[0] is the binary path), but reach for flag first, because it gives you parsing, defaults, and this usage message for free.

Exit codes, and the trap they spring

A command-line tool communicates success or failure to whatever ran it through its exit code: zero means success, non-zero means something went wrong, and shells and CI systems branch on it. Your main returning normally is an exit code of 0. To exit with a specific non-zero code, you call os.Exit:

func main() {
	defer fmt.Println("this deferred line will NOT print")

	fmt.Println("about to exit with code 1")
	os.Exit(1)
}

Run it and check the code the shell saw with echo $?:

$ ./exitdemo
about to exit with code 1
exit code: 1

The deferred line never printed, and that is the trap. os.Exit terminates the process immediately and does not run deferred functions. Every defer you were relying on to close a file, flush a buffer, or release a lock is silently skipped. This is a genuine footgun: you diligently defer f.Close(), then somewhere deep in the call stack an error handler calls os.Exit(1), and your buffered writer never flushes, so the output file is empty and nothing tells you why. The lesson is to keep os.Exit at the very top of your program, in main, after all the deferred cleanup has had its chance to run, and to never call it from deep inside your logic. A common structure is a run() error function that does the real work with all its defers, and a tiny main that calls it and translates the returned error into an exit code:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

Now every defer inside run fires on the way out before main ever reaches os.Exit.

Stdout is for results, stderr is for everything else

There are two output streams, and using them correctly is part of the contract a CLI has with the pipelines it lives in. os.Stdout is for the tool’s actual result, the data a user might pipe into another command. os.Stderr is for diagnostics: errors, warnings, progress. Keeping them separate is what lets a user write tool > out.txt 2> err.txt and get clean data in one file and the noise in another:

fmt.Fprintln(os.Stdout, "result: 42")
fmt.Fprintln(os.Stderr, "warning: using default config")
$ ./stderr 2>/dev/null
result: 42

With stderr discarded, only the result survives, which is exactly what a downstream | grep or | jq wants. Note the Fprintln form: fmt.Println writes to stdout, but fmt.Fprintln(os.Stderr, ...) lets you pick the stream explicitly. Error messages go to stderr, always. A tool that prints its errors to stdout corrupts the very data someone is trying to pipe, and it is one of the most common mistakes in a first CLI.

Subcommands with flag.NewFlagSet

Once a tool grows past one job it wants subcommands, the git commit / git push shape where the first word selects a mode and each mode has its own flags. The flag package supports this through flag.NewFlagSet, which creates an independent set of flags you parse yourself against a slice of arguments:

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "expected 'add' or 'list' subcommand")
		os.Exit(2)
	}

	addCmd := flag.NewFlagSet("add", flag.ExitOnError)
	title := addCmd.String("title", "", "book title to add")

	listCmd := flag.NewFlagSet("list", flag.ExitOnError)
	all := listCmd.Bool("all", false, "include archived books")

	switch os.Args[1] {
	case "add":
		addCmd.Parse(os.Args[2:])
		fmt.Printf("add: title=%q\n", *title)
	case "list":
		listCmd.Parse(os.Args[2:])
		fmt.Printf("list: all=%v\n", *all)
	default:
		fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
		os.Exit(2)
	}
}

The pattern is: switch on os.Args[1] to pick the subcommand, then hand the remaining arguments (os.Args[2:]) to that subcommand’s own flag set. Each subcommand has its own flags, its own defaults, and its own usage. Built and run:

$ ./sub add -title="Go in Practice"
add: title="Go in Practice"
exit code: 0

$ ./sub list -all
list: all=true
exit code: 0

$ ./sub
expected 'add' or 'list' subcommand
exit code: 2

$ ./sub frobnicate
unknown subcommand "frobnicate"
exit code: 2

Two things to read off those exit codes. The valid subcommands exited 0. The two error paths, no subcommand and an unknown one, exited 2, a distinct non-zero code that says “you invoked me wrong” as opposed to “the work failed.” Using different non-zero codes for different failure classes is a courtesy to scripts that call your tool, and it costs nothing. The flag.ExitOnError passed to each NewFlagSet means a bad flag within a subcommand (say add -bogus) prints an error and exits automatically, which is usually what you want for a CLI.

One caveat worth flagging: when you run these through go run instead of a built binary, the exit codes above look different. go run reports a failing program as exit status 2 in its own output but then itself exits 1, so echo $? shows 1 regardless of the code your program chose. To observe the real exit code, build the binary with go build and run that, which is how the numbers above were produced. It is a small thing, but if you ever assert on exit codes in a test script, test the built binary, not go run.

Final thoughts

A production-grade CLI needs almost nothing beyond the standard library. flag parses named options into pointers you read after flag.Parse, hands you positionals through flag.Args, and generates the -h usage table from the same declarations so it cannot drift. os.Exit sets the exit code but skips every deferred function, so keep it in main behind a run() error and let cleanup happen first. Send results to stdout and diagnostics to stderr so your tool composes in a pipeline, use distinct non-zero codes for distinct failures, and reach for flag.NewFlagSet when one command grows into several. Build the binary to see the real exit codes, and you have a tool, not a script.

Next: talking to a database

Comments