Go for CLI Tools
How to build command line programs in Go that read from pipes, fail properly, handle signals and ship as one binary anyone can run.
Most teams reach for Bash first. It is already installed, and a twenty line script solves the problem. Then the script grows. It picks up argument parsing, error handling, a bit of JSON, and eventually nobody wants to touch it because a stray unquoted variable can delete the wrong directory.
Go sits in a useful place for that kind of work. It compiles to a single binary with no runtime to install, it starts instantly, and the standard library covers most of what a script needs. Here is what actually matters when you write one.
The shape of a Go program
A command line tool is a main package with a main function. That is the whole ceremony.
package main
import "fmt"
func main() {
fmt.Println("hello")
}Run it with go run . during development, and go build when you want the binary. Initialise the module first with go mod init example.com/mytool so imports resolve properly.
Arguments and flags
os.Args gives you the raw slice, with the program name at index zero. It is fine for a tool that takes one positional argument and nothing else. Beyond that, use the flag package from the standard library.
package main
import (
"flag"
"fmt"
)
func main() {
var (
verbose = flag.Bool("verbose", false, "print progress to stderr")
workers = flag.Int("workers", 4, "number of concurrent workers")
output = flag.String("out", "", "output file (default stdout)")
)
flag.Parse()
paths := flag.Args() // whatever is left after the flags
fmt.Println(*verbose, *workers, *output, paths)
}You get -help for free, generated from those description strings. Write them as if a stranger will read them, because they will.
Once a tool grows subcommands, flag.NewFlagSet lets you give each one its own flags and switch on os.Args[1]. If you find yourself building nested subcommands, completions and generated docs, that is the point to bring in Cobra. Not before.
Standard input, output and errors
This is the part scripts get wrong most often, and it is what separates a tool that composes from one that has to be worked around.
Results go to stdout. Everything else, progress, warnings, errors, goes to stderr. That way mytool data.csv > results.json writes only real output to the file, and the user still sees what went wrong on their terminal.
fmt.Fprintln(os.Stdout, "the actual result")
fmt.Fprintln(os.Stderr, "reading 4 files...")Reading from a pipe is what makes a tool usable inside a larger command. bufio.Scanner handles this line by line without loading the whole stream into memory.
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
fmt.Println(strings.ToUpper(line))
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "read failed:", err)
os.Exit(1)
}That scanner.Err() check matters. The loop ends both on clean end of input and on a read failure, and without the check the two look identical.
A common convention is to read from files when paths are given and fall back to stdin when they are not. It costs a few lines and makes the tool feel native.
func input(paths []string) (io.Reader, error) {
if len(paths) == 0 {
return os.Stdin, nil
}
return os.Open(paths[0])
}Exit codes
Zero means success. Anything else means failure. This is not decoration: it is how &&, CI pipelines and shell scripts decide whether to carry on.
A pattern that keeps this tidy is to put the real work in a function that returns an error, and let main do nothing but translate that into an exit code.
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run() error {
// everything happens here
return nil
}The reason for the split is that os.Exit terminates immediately and skips every deferred call. Any file you meant to close or temporary directory you meant to remove is simply left behind. Keeping os.Exit in main alone means your defers in run always fire.
Errors
Go returns errors as values instead of throwing them. It is more typing, and it means every failure is visible at the point it can happen.
Wrap errors with %w so context accumulates as they travel up:
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading config %s: %w", path, err)
}The user sees error: reading config /etc/app.yaml: no such file or directory rather than a bare permission complaint with no clue which file caused it. When you need to react to a specific failure rather than just report it, errors.Is and errors.As inspect the wrapped chain.
if errors.Is(err, os.ErrNotExist) {
// fall back to defaults
}Reserve panic for genuine programmer error. A missing file is not exceptional, it is Tuesday.
Running other commands
Plenty of tools are glue around existing binaries. os/exec covers it, and unlike a shell it passes arguments as a list, so nothing gets word split or globbed behind your back.
cmd := exec.Command("git", "rev-parse", "--short", "HEAD")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("git rev-parse: %w", err)
}
commit := strings.TrimSpace(string(out))Use cmd.Run() when you only care whether it succeeded, cmd.Output() for stdout, and cmd.CombinedOutput() when you want stderr folded in for a log. Setting cmd.Stdout = os.Stdout streams the child process output straight through, which is what you want for anything long running.
Avoid exec.Command("sh", "-c", userInput). That reintroduces every injection problem you moved away from Bash to escape.
Signals and cancellation
Anything that runs longer than a second should react to Ctrl+C. signal.NotifyContext makes this three lines.
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)Pass that context into anything that accepts one and cancellation propagates through the whole call tree. The first Ctrl+C triggers a clean shutdown, and the second kills the process outright, which is exactly the behaviour people expect.
Concurrency, when it earns its place
Goroutines are the reason a Go tool can be dramatically faster than the script it replaced. Fetching two hundred URLs one at a time is slow for no good reason.
sem := make(chan struct{}, 10) // cap at 10 in flight
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fetch(url)
}()
}
wg.Wait()Note the bounded semaphore. Launching a goroutine per item is easy and will happily open ten thousand sockets at once. Also note there is no loop variable captured by hand: since Go 1.22 each iteration gets its own, and the old bug where every goroutine saw the final value is gone.
For anything more involved, errgroup from golang.org/x/sync handles waiting, error collection and context cancellation together.
Shipping it
This is where Go pays off for internal tooling. Building for another platform is two environment variables, no container and no toolchain to install:
GOOS=linux GOARCH=amd64 go build -o mytool-linux
GOOS=darwin GOARCH=arm64 go build -o mytool-macYou can strip the binary down and stamp in a version at build time:
go build -ldflags="-s -w -X main.version=1.4.0" -o mytoolThe result is one file. Copy it onto a server, into a CI image or onto a colleague's laptop and it runs. No interpreter version, no virtual environment, no dependency install step that works on your machine and fails on theirs.
Testing
Tools built the way described above test easily, because the logic is in ordinary functions rather than tangled up with os.Stdout. Accept an io.Reader and an io.Writer instead of reaching for the globals, and a test can hand you a buffer.
func TestProcess(t *testing.T) {
in := strings.NewReader("alpha\nbeta\n")
var out bytes.Buffer
if err := process(in, &out); err != nil {
t.Fatal(err)
}
if got := out.String(); got != "ALPHA\nBETA\n" {
t.Errorf("got %q", got)
}
}Table driven tests are the idiom for covering a range of inputs, and t.TempDir() gives you a directory that cleans itself up when anything touches the filesystem.
Worth knowing
Bash is still the right answer for five lines of glue. Go earns its place when a script needs real argument handling, concurrency, cross platform distribution or a test suite, and especially when other people have to run it. The rewrite is usually smaller than expected, because the standard library already covers files, HTTP, JSON, templating and process control without a single dependency.
If you are replacing ageing scripts with something maintainable, or building internal tooling that needs to run reliably across a team, talk to Eight Mile. Custom software, backend systems, cloud infrastructure and workflow automation are the work we take on.