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

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

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.

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.

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

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 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.

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.

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:

# 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 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.

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. But the next time you catch yourself thinking “I should rewrite this script properly,” give Go an afternoon. It’s a remarkably short distance from zero to genuinely useful.

Advertisement

Related Content

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.