Skip to main content
Go · lesson 24 of 25
Go intermediate Lesson 24 of 25

CLI Tools in Go

Go command-line tools run for real: the flag package and -h, stdin pipes, stdout vs stderr, exit codes and os.Exit, env vars, subcommands, and cobra.

By SkillByExample · Updated

A good command-line tool reads flags and arguments, streams data through stdin and stdout, reports problems on stderr, and exits with a meaningful status. Go’s standard library covers all of it; every example here is built once with go build and then run as a real command.

Flags with the flag package

flag defines typed flags with defaults and help text, and generates -h output for free.

package main

import (
	"flag"
	"fmt"
	"strings"
)

func main() {
	name := flag.String("name", "World", "who to greet")
	count := flag.Int("n", 1, "number of greetings")
	shout := flag.Bool("shout", false, "print in upper case")
	flag.Parse()

	msg := fmt.Sprintf("Hello, %s!", *name)
	if *shout {
		msg = strings.ToUpper(msg)
	}
	for range *count {
		fmt.Println(msg)
	}
	fmt.Println("extra args:", flag.Args())
}
$ go build -o greet .
$ ./greet
Hello, World!
extra args: []
$ ./greet -name Gopher -n 2 --shout extra1 extra2
HELLO, GOPHER!
HELLO, GOPHER!
extra args: [extra1 extra2]
$ ./greet -n two; echo "exit status $?"
invalid value "two" for flag -n: parse error
Usage of ./greet:
  -n int
    	number of greetings (default 1)
  -name string
    	who to greet (default "World")
  -shout
    	print in upper case
exit status 2
$ ./greet -h; echo "exit status $?"
Usage of ./greet:
  -n int
    	number of greetings (default 1)
  -name string
    	who to greet (default "World")
  -shout
    	print in upper case
exit status 0

flag.String and friends return pointers, filled in by flag.Parse(). One or two dashes both work, and boolean flags need no value. Anything after the flags is in flag.Args(). An invalid value prints the error and the usage, then exits with status 2 — the Unix convention for bad usage. -h prints the generated help and exits 0. Note that flag stops at the first non-flag argument: ./greet extra -n 2 would treat -n and 2 as plain arguments.

stdin, stdout and stderr

A filter reads stdin when given no files, writes results to stdout, and sends errors to stderr so they don’t pollute piped data.

package main

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

func countLines(r io.Reader) (int, error) {
	sc := bufio.NewScanner(r)
	n := 0
	for sc.Scan() {
		n++
	}
	return n, sc.Err()
}

func main() {
	if len(os.Args) == 1 {
		n, _ := countLines(os.Stdin)
		fmt.Println(n)
		return
	}
	failed := false
	for _, path := range os.Args[1:] {
		f, err := os.Open(path)
		if err != nil {
			fmt.Fprintln(os.Stderr, "count:", err)
			failed = true
			continue
		}
		n, _ := countLines(f)
		f.Close()
		fmt.Printf("%d %s\n", n, path)
	}
	if failed {
		os.Exit(1)
	}
}
$ go build -o count .
$ printf 'a\nb\nc\n' | ./count
3
$ printf 'one\ntwo\n' > two.txt
$ ./count two.txt main.go missing.txt; echo "exit status $?"
2 two.txt
40 main.go
count: open missing.txt: no such file or directory
exit status 1
$ ./count two.txt missing.txt 2>/dev/null
2 two.txt
$ ./count two.txt missing.txt 2>&1 >/dev/null
count: open missing.txt: no such file or directory

With no arguments, the tool read the piped text; with file names it processed each one, carrying on past the missing file but remembering the failure for the exit status. The last two lines separate the streams: 2>/dev/null hides the error and leaves the data, while 2>&1 >/dev/null keeps only the error. A script calling count can test $? and still trust stdout to contain only counts.

Exit codes and os.Exit

os.Exit ends the process immediately — deferred functions do not run.

package main

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

func run() error {
	defer fmt.Println("cleanup in run")
	return errors.New("config file not found")
}

func main() {
	defer fmt.Println("deferred in main: never printed")
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go build -o exitdemo .
$ ./exitdemo; echo "exit status $?"
cleanup in run
error: config file not found
exit status 1

run’s deferred cleanup happened because run returned normally; main’s did not, because os.Exit doesn’t unwind the stack. The usual shape is therefore a small main that calls run() error, does all real work and cleanup in run, and calls os.Exit only at the end. log.Fatal calls os.Exit(1) too, with the same effect on defers.

Environment variables

Configuration that differs per deployment often comes from the environment.

package main

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

func main() {
	port := 8080
	if v, ok := os.LookupEnv("APP_PORT"); ok {
		p, err := strconv.Atoi(v)
		if err != nil {
			fmt.Fprintf(os.Stderr, "APP_PORT=%q is not a number\n", v)
			os.Exit(2)
		}
		port = p
	}
	fmt.Println("port:", port, "| debug:", os.Getenv("APP_DEBUG") == "1")
}
$ go build -o envdemo .
$ ./envdemo
port: 8080 | debug: false
$ APP_PORT=9090 APP_DEBUG=1 ./envdemo
port: 9090 | debug: true
$ APP_PORT=abc ./envdemo; echo "exit status $?"
APP_PORT="abc" is not a number
exit status 2

os.Getenv returns "" for both “unset” and “set to empty”; os.LookupEnv tells them apart with its second result. Validate environment values at startup and fail fast, as here, rather than discovering a bad port deep inside the program.

Subcommands with flag.FlagSet

Each subcommand gets its own FlagSet, with its own flags and help.

package main

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

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: todo <add|list> [flags]")
		os.Exit(2)
	}
	switch os.Args[1] {
	case "add":
		fs := flag.NewFlagSet("add", flag.ExitOnError)
		prio := fs.Int("p", 3, "priority 1-5")
		fs.Parse(os.Args[2:])
		fmt.Printf("added %q with priority %d\n", fs.Arg(0), *prio)
	case "list":
		fs := flag.NewFlagSet("list", flag.ExitOnError)
		all := fs.Bool("all", false, "include done items")
		fs.Parse(os.Args[2:])
		fmt.Println("listing, all =", *all)
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
		os.Exit(2)
	}
}
$ go build -o todo .
$ ./todo add -p 1 "ship lesson"
added "ship lesson" with priority 1
$ ./todo list -all
listing, all = true
$ ./todo add -h; echo "exit status $?"
Usage of add:
  -p int
    	priority 1-5 (default 3)
exit status 0
$ ./todo remove; echo "exit status $?"
unknown command "remove"
exit status 2

The go command itself is built this way. Subcommand flags come after the subcommand name, and each FlagSet prints only its own flags for -h.

Cobra

For larger tools with many subcommands, cobra adds GNU-style --long/-s flags, generated help for every command, and shell completion.

package main

import (
	"fmt"
	"os"
	"strings"

	"github.com/spf13/cobra"
)

func main() {
	var upper bool
	root := &cobra.Command{Use: "greet", Short: "A friendly greeter"}
	hello := &cobra.Command{
		Use:   "hello NAME",
		Short: "Say hello",
		Args:  cobra.ExactArgs(1),
		Run: func(cmd *cobra.Command, args []string) {
			msg := "hello, " + args[0]
			if upper {
				msg = strings.ToUpper(msg[:1]) + msg[1:]
			}
			fmt.Println(msg)
		},
	}
	hello.Flags().BoolVarP(&upper, "capitalize", "c", false, "capitalize the greeting")
	root.AddCommand(hello)
	if err := root.Execute(); err != nil {
		os.Exit(1)
	}
}
$ go get github.com/spf13/cobra@v1.10.2
go: downloading github.com/inconshreveable/mousetrap v1.1.0
go: downloading github.com/spf13/pflag v1.0.9
go: added github.com/inconshreveable/mousetrap v1.1.0
go: added github.com/spf13/cobra v1.10.2
go: added github.com/spf13/pflag v1.0.9
$ go build -o greet .
$ ./greet hello Ann
hello, Ann
$ ./greet hello -c Ann
Hello, Ann
$ ./greet hello; echo "exit status $?"
Error: accepts 1 arg(s), received 0
Usage:
  greet hello NAME [flags]

Flags:
  -c, --capitalize   capitalize the greeting
  -h, --help         help for hello

exit status 1
$ ./greet --help
A friendly greeter

Usage:
  greet [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  hello       Say hello
  help        Help about any command

Flags:
  -h, --help   help for greet

Use "greet [command] --help" for more information about a command.

Cobra validated the argument count (ExactArgs(1)) and printed the error together with the command’s usage. The root command’s help lists subcommands, including the completion command cobra adds automatically for bash, zsh, fish and PowerShell. Whether that’s worth a dependency is a judgment call: for one command with a few flags, flag is enough.

Practice

1. Add a -w flag to count that counts words instead of lines.

Solution
package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
)

func main() {
	words := flag.Bool("w", false, "count words instead of lines")
	flag.Parse()

	sc := bufio.NewScanner(os.Stdin)
	if *words {
		sc.Split(bufio.ScanWords)
	}
	n := 0
	for sc.Scan() {
		n++
	}
	fmt.Println(n)
}
$ go build -o count .
$ printf 'go is fun\nand fast\n' | ./count
2
$ printf 'go is fun\nand fast\n' | ./count -w
5

bufio.ScanWords changes what one Scan returns; the counting loop doesn’t change.

2. Make greet reject -n values below 1 with a message on stderr and exit status 2.

Solution
package main

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

func main() {
	count := flag.Int("n", 1, "number of greetings (>= 1)")
	flag.Parse()
	if *count < 1 {
		fmt.Fprintf(os.Stderr, "greet: -n must be at least 1, got %d\n", *count)
		flag.Usage()
		os.Exit(2)
	}
	for range *count {
		fmt.Println("Hello!")
	}
}
$ go build -o greet .
$ ./greet -n 0; echo "exit status $?"
greet: -n must be at least 1, got 0
Usage of ./greet:
  -n int
    	number of greetings (>= 1) (default 1)
exit status 2

flag only checks that the value is an integer; range checks are yours. Calling flag.Usage() prints the same help -h would.

3. Write a tool that upper-cases stdin to stdout, line by line, so it can sit in the middle of a pipeline.

Solution
package main

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

func main() {
	sc := bufio.NewScanner(os.Stdin)
	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	for sc.Scan() {
		fmt.Fprintln(w, strings.ToUpper(sc.Text()))
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "upper:", err)
	}
}
$ go build -o upper .
$ printf 'hello\nworld\n' | ./upper | sort -r
WORLD
HELLO

Buffering stdout with bufio.Writer avoids one system call per line, which matters for large inputs; the deferred Flush writes the remainder before exit (this tool never calls os.Exit, so the defer runs).

Frequently Asked Questions

Should I use the flag package or cobra for my CLI?
Start with the standard flag package: it parses typed flags, generates -h help and needs no dependency. Move to cobra when you have several subcommands, nested commands, GNU-style --long flags and shell completion, as in tools like kubectl or gh.
How do I read from stdin in Go?
Wrap os.Stdin in bufio.NewScanner to read line by line, or call io.ReadAll(os.Stdin) to read everything. Reading stdin when no file arguments are given lets your tool sit in a Unix pipeline like any other command.
How do I exit a Go program with a specific exit code?
Call os.Exit(code): 0 means success and anything else failure, with 2 conventionally meaning bad usage. os.Exit ends the process immediately without running deferred functions, so do it once at the top of main, after a run function has returned and cleaned up.
Why should errors go to stderr instead of stdout?
stdout is the program's data, which may be piped into another program or redirected into a file. Writing errors and progress messages to stderr keeps them visible on the terminal without corrupting that data. In Go, use fmt.Fprintln(os.Stderr, ...) or the log package.
How do I build subcommands without a framework in Go?
Switch on os.Args[1] and give each subcommand its own flag.NewFlagSet, parsing os.Args[2:] with it. Each subcommand then has independent flags and its own -h output, which is how the go command itself is structured.