Contents

Writing CLI Tools in Go: From Zero to Useful in an Afternoon

Why Go is the path of least resistance for command-line utilities

Contents

I write a lot of little command-line tools. Glue that wires two APIs together, a thing that munges a CSV the way I actually need it, a daemon that watches a directory and pokes something when a file lands. For years my reflex was a Bash script that grew tentacles, or a Python file that worked fine on my machine and nowhere else. These days I reach for Go, and I keep reaching for it because the gap between “idea” and “a binary I can hand to someone” is genuinely about an afternoon. Here’s why, and how.

Why Go for CLIs specifically

Advertisement

The killer feature is deployment. go build produces a single, statically-linked binary with no runtime, no interpreter, no pip install, no virtualenv. You copy one file to a server and run it. Cross-compiling for another OS or architecture is a pair of environment variables, which I’ll show you in a moment. After years of explaining to people why my Python tool needs a specific interpreter version, handing over one self-contained file feels like cheating.

Beyond that, the language fits CLI work nicely. Startup is instant, so it feels snappy in a way a JVM tool never quite does. The standard library covers flags, JSON, HTTP and file handling without third-party packages. And the type system catches the dim mistakes at compile time rather than at 2am in production.

There is a comparison worth making with the other language I reach for. Python is faster to write — no compile step, a REPL, batteries included — but the moment a tool has to run somewhere other than your machine, the deployment story falls apart: which interpreter, which version, which virtualenv, which twelve pip packages. Python earns its keep for one-off scripts and anything data-heavy, and it has genuinely improved as a maintainable language now that type hints let you write Python like you mean it. But for something you hand to a colleague or drop on a server, the type system and the single binary are exactly the properties that make the difference, and Go wins on both.

The absolute minimum

You don’t need a framework to start. The standard library’s flag package handles arguments perfectly well for small tools.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

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

func main() {
	name := flag.String("name", "world", "who to greet")
	shout := flag.Bool("shout", false, "use uppercase")
	flag.Parse()

	greeting := fmt.Sprintf("Hello, %s!", *name)
	if *shout {
		greeting = fmt.Sprintf("HELLO, %s!", *name)
	}
	fmt.Println(greeting)
	os.Exit(0)
}

Initialise a module with go mod init github.com/smarc/greet, run go build, and you have a greet binary. ./greet -name Smarc -shout does what you’d expect, and ./greet -h prints usage automatically. For a tool with a handful of flags, this is honestly all you need, and there’s something to be said for zero dependencies.

The thing worth internalising is how little ceremony that was. No package.json, no requirements.txt, no __init__.py, no build system to configure — one file, one go build, one binary. The standard library did the argument parsing, the help text, and the type conversion, and the whole program is legible to anyone who has seen C-family syntax. That low activation energy is the real reason “an afternoon” is not hyperbole: you spend your time on the logic that is actually specific to your problem, not on scaffolding. I have written genuinely useful tools — a log-munger, a little deploy wrapper — that never grew past this single-file flag stage and never needed to.

When you outgrow the standard library

Advertisement

The moment you want subcommands — mytool sync, mytool status, mytool config set — the flag package starts to feel cramped. This is where Cobra comes in. It’s the library behind kubectl, gh, docker and most of the Go CLIs you already use, so its conventions are the ones users expect.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
	"fmt"

	"github.com/spf13/cobra"
)

func main() {
	var verbose bool

	root := &cobra.Command{
		Use:   "deploy",
		Short: "Deploy things, carefully",
	}
	root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "chatty output")

	staging := &cobra.Command{
		Use:   "staging",
		Short: "Deploy to staging",
		Run: func(cmd *cobra.Command, args []string) {
			if verbose {
				fmt.Println("connecting to staging...")
			}
			fmt.Println("deployed to staging")
		},
	}

	root.AddCommand(staging)
	root.Execute()
}

Cobra gives you nested subcommands, persistent flags that apply to children, auto-generated help, and shell completion for Bash, Zsh and Fish for free. Pair it with its sibling Viper if you want config files and environment-variable binding without writing the plumbing yourself. There’s a cobra-cli generator too, but I usually find hand-writing the command tree clearer for a small tool.

The reason to adopt Cobra is not that it does something you couldn’t do with flag — it is that it does it the way users already expect, because they have been trained by kubectl, gh, and docker. The --help output has the same shape, subcommands nest the same way, completion behaves the same. Matching those conventions means nobody has to learn your tool’s peculiarities, which is a real usability win the moment more than one person touches it. My rule of thumb: reach for Cobra the instant you have a second subcommand or you want config-file support, and stay on the standard library until then. Adding a framework to a single-verb tool is over-engineering; refusing to add one to a growing multi-command tool is stubbornness. The good news is that migrating from flag to Cobra is mechanical — you are moving argument definitions, not rewriting logic — so there is no penalty for starting simple and graduating later.

Distribution is the whole point

Here’s the part that sells Go to anyone who’s ever fought a deployment. Building for other platforms from your laptop is just setting GOOS and GOARCH:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Linux on a typical server
GOOS=linux GOARCH=amd64 go build -o dist/deploy-linux-amd64

# Apple Silicon Mac
GOOS=darwin GOARCH=arm64 go build -o dist/deploy-darwin-arm64

# Windows
GOOS=windows GOARCH=amd64 go build -o dist/deploy.exe

# strip debug info to shrink the binary a little
go build -ldflags="-s -w" -o dist/deploy

No cross-compilers to install, no toolchain wrangling. When you’re ready to ship properly, GoReleaser automates the whole matrix: it builds every target, produces archives and checksums, cuts a GitHub release and can even publish a Homebrew formula, all from one YAML file in CI. The first time you watch it spit out binaries for five platforms from a git tag, the appeal is obvious.

It is hard to overstate how much friction this removes from the unglamorous last mile of a tool’s life. With an interpreted language, “how do I install your thing” spawns a support conversation about runtime versions, package managers, and PATH — and that conversation happens every time a new colleague picks it up. With a Go binary the answer is “download this file and run it”, full stop, and it keeps working two years later when the surrounding ecosystem has moved on three major versions. That durability is a quiet superpower: a tool you wrote to solve a problem once does not rot into a “sorry, that needs an old Python” liability. The distribution story is not a footnote to writing a CLI in Go; for anything meant to outlive the afternoon you wrote it in, it is the main event, and it is the single reason I keep choosing the language over faster-to-prototype alternatives.

The gotchas that trip people up

A handful of things reliably catch newcomers, and knowing them ahead of time saves the afternoon from turning into an evening.

CGO breaks the “single binary” promise. The static-linking magic only holds while your build is pure Go. The moment you pull in a dependency that binds to C — the classic offender is the mattn/go-sqlite3 SQLite driver — go build quietly links against the system libc, and your “portable” binary now refuses to run on a host with a different glibc. If you need a truly static binary, either set CGO_ENABLED=0 (and use a pure-Go SQLite like modernc.org/sqlite) or build inside the target’s environment. This is the number-one “it works on my machine” surprise in Go CLIs.

Cross-compiled binaries that use net can behave oddly. Go’s default resolver has a pure-Go path and a cgo path; a binary cross-compiled with CGO_ENABLED=0 always uses the pure-Go resolver, which is usually what you want but occasionally reads /etc/hosts and DNS differently than the C library would. If name resolution misbehaves only on the cross-built binary, this is why.

Flag parsing order matters with flag. The standard library’s flag package stops parsing at the first non-flag argument, so mytool file.txt -verbose silently ignores -verbose. Put flags before positional arguments, or move to Cobra, which handles interspersed flags the way users expect.

go build caches aggressively. If a rebuild seems to ignore a change, go clean -cache is the reset button — rarely needed, but baffling the one time it is.

The honest trade-offs

It isn’t all sunshine. Go’s error handling is famously verbose — you will type if err != nil { return err } more times than feels reasonable, and that’s just the deal. The binaries are larger than a C equivalent because the runtime is bundled in, though that’s the same property that makes them so portable. And for genuinely throwaway, ten-line glue, a shell script is still faster to write; Go shines once a tool is going to live longer than a day or be used by someone other than you.

A well-behaved CLI is also more than argument parsing. Respect the Unix conventions your users already know: write real output to stdout and diagnostics to stderr so pipelines work, return a non-zero exit code on failure so && chains and CI can branch on it, and honour NO_COLOR if you emit colour. These are the small courtesies that separate a tool people tolerate from one they reach for — the same “make it pleasant for the next human” instinct behind generating decent git commit messages rather than shipping wip into the log forever.

Is it worth it, and for whom

If you’re writing internal tooling, anything you’ll distribute to colleagues, or a utility you want to still work in two years, Go is close to the ideal choice. The standard library gets you off the ground in minutes, Cobra scales you up to a polished multi-command tool, and the single-binary distribution story turns “how do I install your thing” from a support ticket into a download. For one-off scripting on your own machine, stick with Bash or Python; there is no shame in the right tool for a five-minute job. But the next time you catch yourself thinking “I should rewrite this script properly” — the moment a throwaway hack has quietly become something you depend on and other people are starting to ask for — give Go an afternoon. The standard library gets you off the ground before the tea goes cold, Cobra is there when you outgrow it, and the single self-contained binary at the end is the part your users will actually thank you for. It’s a remarkably short distance from zero to genuinely useful, and an even shorter one from useful to something you are glad you built.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.