I remember the first CLI tool I built in Go. It was a simple file watcher that recompiled my project whenever I saved a file. The code was messy, error messages were cryptic, and if the user hit Ctrl+C, the program just vanished without cleanup. A few months later, I had to maintain that tool for a team of twenty developers. That’s when I learned that building a CLI that people can rely on requires more than just getting the job done. You need patterns. These nine patterns turned my flaky scripts into tools others trust.
Let’s start with the most obvious one: Command Composition. When your tool does more than one thing, you need subcommands. Think of git commit, git push, git log. Each subcommand lives under the parent git. In Go, the cobra library makes this natural. You define a root command, then add nested commands under it. Each subcommand gets its own Run function, arguments, and flags. This keeps the code organized and lets users discover functionality through --help.
Here’s how I structure a CLI with two subcommands:
var rootCmd = &cobra.Command{
Use: "mytool",
Short: "MyTool is a CLI for managing stuff",
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the server",
Run: serveFunc,
}
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage configuration",
}
var configGetCmd = &cobra.Command{
Use: "get [key]",
Short: "Get a configuration value",
Args: cobra.ExactArgs(1),
Run: configGetFunc,
}
func init() {
rootCmd.AddCommand(serveCmd)
configCmd.AddCommand(configGetCmd)
rootCmd.AddCommand(configCmd)
}
I once had a tool with a single Run function that handled every possible operation based on flags. That became unreadable fast. Subcommands are the cure.
Now, Context-Aware Execution. Your CLI will run in all sorts of environments. Users will press Ctrl+C in the middle of a long operation. You need to handle that gracefully. Go’s context package is your friend. Pass a context from your command’s Run function down to every blocking call—HTTP requests, database queries, file operations. Bind the context to the OS signal for interrupt.
func serveFunc(cmd *cobra.Command, args []string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Listen for interrupt
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
cancel()
}()
startServer(ctx)
}
Inside startServer, you check ctx.Done() to shut down cleanly. I built a data migration tool that could take hours. Before I added context, users had to kill the process with kill -9, often leaving the system in a broken state. After adding context-aware cancellation, a simple Ctrl+C would roll back or checkpoint. Huge difference.
Structured Exit Codes are the silent heroes of CLI usability. Shell scripts and CI pipelines check exit codes. Use os.Exit(0) for success, os.Exit(1) for generic errors, os.Exit(2) for wrong arguments, and os.Exit(3) for unavailable resources. Cobra’s RunE returns an error, and you decide when to exit. I always silence Cobra’s default error printing so I can print a clean message to stderr myself.
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
If the error is a usage problem, I show the usage first, then exit with code 2.
Now, Configuration Layering. No one likes passing ten flags every time. Let users set defaults via a config file, then override with environment variables, then with flags. Viper does the heavy lifting. Define a struct, bind it to Viper, and it populates from multiple sources.
type Config struct {
Port int `mapstructure:"port"`
Verbose bool `mapstructure:"verbose"`
}
func loadConfig() (*Config, error) {
v := viper.New()
v.SetConfigName(".mytool")
v.AddConfigPath("$HOME")
v.SetEnvPrefix("MYTOOL")
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, err
}
}
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, err
}
return cfg, nil
}
Then bind flags to the same keys. The precedence is flag > env > config file > default. I always document this hierarchy in the help text.
Progress Reporting keeps users from staring at a blank terminal. Write progress to stderr, never to stdout. That way, piping the output to a file doesn’t get corrupted with spinner characters. For known-length operations, use a progress bar. For unknown lengths, a simple spinner.
bar := progressbar.Default(100)
for i := 0; i < 100; i++ {
// do work
bar.Add(1)
}
I once wrote a backup tool that ran for two hours with no output. Users thought it hung. Adding a progress bar stopped support tickets.
Input Validation should happen as early as possible. Cobra lets you define Args validators like cobra.ExactArgs(2). You can also write custom ones. If a file must exist, check it before any heavy processing. Return a clear message that says exactly what went wrong and how to fix it.
var convertCmd = &cobra.Command{
Use: "convert [input] [output]",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if _, err := os.Stat(args[0]); os.IsNotExist(err) {
return fmt.Errorf("input file %q does not exist", args[0])
}
// proceed
},
}
Idempotent Operations make your tool safe to run multiple times. Before creating a resource, check if it already exists. Support a --dry-run flag that shows what would happen without actually doing it. For operations that modify state, use a lock file to prevent two processes from stepping on each other.
func createResource(dryRun bool) error {
if dryRun {
fmt.Println("Would create resource: my-resource")
return nil
}
// create
}
I learned this the hard way when a cron job accidentally created duplicate database entries.
Testing with Golden Files ensures your CLI output doesn’t change unexpectedly. Write a test that runs your command, captures stdout, and compares it to a saved file (the “golden” file). If the output changes intentionally, update the golden file. This catches formatting bugs and error message changes.
func TestVersionOutput(t *testing.T) {
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"version"})
rootCmd.Execute()
golden := "testdata/version.golden"
expected, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("could not read golden file: %v", err)
}
if !bytes.Equal(buf.Bytes(), expected) {
t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", buf.Bytes(), expected)
}
}
I use go test -update to automatically regenerate golden files when I know the output changed. This pattern is simple and catches surprises.
Self-Documentation makes your tool easy to use without reading a manual. Cobra generates nice help text automatically. But you can go further: add a --help flag for every subcommand, a --version flag, and shell completion scripts. Generating completions for bash, zsh, and fish is a one-liner.
rootCmd.AddCommand(&cobra.Command{
Use: "completion [bash|zsh|fish]",
Short: "Generate shell completion scripts",
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
})
Users can then source the completed script and get tab completion. It takes ten minutes to add and saves them hours.
These patterns are not magic. They are built from practical experience. When I follow them, my CLI tools become predictable, safe, and easy to maintain. I start every new project with a cobra scaffold, add context handling early, and never skip exit codes. The result is a tool that works in the messy real world—whether it’s run by a human or a machine.
I hope these patterns help you turn your Go CLI from a quick hack into something you’re proud to ship.