Go has become the default language for CLI tools, and for good reason. Single binary distribution, fast compilation, excellent standard library, and cross-compilation that actually works. But there is a vast gap between a tutorial-level CLI that parses a few flags and a production tool that handles edge cases, provides useful error messages, and behaves like software professionals expect.
I have shipped four production CLI tools in Go over the past two years, ranging from an internal deployment tool to an open-source database migration utility with 2,000+ users. This article covers what I wish I knew before the first one.
Start with Cobra, But Understand What It Does
Nearly every serious Go CLI uses Cobra. It handles subcommands, flags, help generation, and shell completions. The standard scaffolding looks like this:
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "mytool",
Short: "A production-ready CLI tool",
Long: `mytool manages deployments, runs migrations,
and handles configuration for your infrastructure.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig()
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default is $HOME/.mytool.yaml)")
rootCmd.PersistentFlags().String("output", "text",
"output format: text, json, yaml")
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
}
What the tutorials skip: Cobra is a framework, not just a library. Once you adopt it, it shapes your entire application structure. The PersistentPreRunE hook is where you initialize configuration, set up logging, and validate global state. Getting this wrong means every subcommand has inconsistent behavior.
Configuration: The Three-Layer Approach
Production CLI tools need configuration from three sources, in order of precedence:
- Command-line flags (highest priority)
- Environment variables
- Configuration file (lowest priority)
Viper handles this naturally, but the binding order matters:
func initConfig() error {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("finding home directory: %w", err)
}
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigName(".mytool")
viper.SetConfigType("yaml")
}
// Environment variables: MYTOOL_DATABASE_URL, MYTOOL_API_KEY, etc.
viper.SetEnvPrefix("MYTOOL")
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("reading config: %w", err)
}
// Config file not found is fine — use defaults and env vars
}
return nil
}
A common mistake: not documenting which environment variables your tool reads. Users should not have to read your source code to figure out that MYTOOL_DATABASE_URL maps to the --database-url flag. Add a mytool config show subcommand that displays the resolved configuration with sources:
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Display resolved configuration and sources",
RunE: func(cmd *cobra.Command, args []string) error {
settings := viper.AllSettings()
for key, value := range settings {
source := "default"
if viper.InConfig(key) {
source = "config file"
}
if os.Getenv("MYTOOL_" + strings.ToUpper(key)) != "" {
source = "environment"
}
if cmd.Flags().Changed(key) {
source = "flag"
}
fmt.Fprintf(os.Stdout, "%-20s = %-30v (source: %s)\n",
key, value, source)
}
return nil
},
}
Error Handling That Respects Users
The single biggest quality gap between amateur and professional CLI tools is error handling. Compare these two error messages:
// Bad: What every tutorial produces
Error: open config.yaml: no such file or directory
// Good: What production tools should produce
Error: Could not read configuration file "config.yaml"
The file does not exist at the expected path:
/home/user/project/config.yaml
To create a default configuration:
mytool config init
To specify a different config file:
mytool --config /path/to/config.yaml
Build a custom error type that carries context and suggestions:
type CLIError struct {
Message string
Cause error
Suggestion string
ExitCode int
}
func (e *CLIError) Error() string {
var b strings.Builder
b.WriteString("Error: " + e.Message + "\n")
if e.Cause != nil {
b.WriteString("\n Cause: " + e.Cause.Error() + "\n")
}
if e.Suggestion != "" {
b.WriteString("\n " + e.Suggestion + "\n")
}
return b.String()
}
func (e *CLIError) Unwrap() error {
return e.Cause
}
// Usage in commands
func connectDatabase(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, &CLIError{
Message: "Failed to connect to database",
Cause: err,
Suggestion: "Check that the database URL is correct and the server is running.\n Current URL: " + maskPassword(dsn),
ExitCode: 1,
}
}
return db, nil
}
Output Formatting: Support Humans and Machines
Every production CLI needs at least two output modes: human-readable text and machine-parseable JSON. Many tools add YAML and table formats as well.
type OutputFormatter interface {
Format(data interface{}) (string, error)
}
type TextFormatter struct{ Writer io.Writer }
type JSONFormatter struct{ Writer io.Writer; Pretty bool }
type TableFormatter struct{ Writer io.Writer; Columns []string }
func NewFormatter(format string, w io.Writer) OutputFormatter {
switch format {
case "json":
return &JSONFormatter{Writer: w, Pretty: true}
case "json-compact":
return &JSONFormatter{Writer: w, Pretty: false}
case "table":
return &TableFormatter{Writer: w}
default:
return &TextFormatter{Writer: w}
}
}
// Table output using tablewriter
func (f *TableFormatter) Format(data interface{}) (string, error) {
table := tablewriter.NewWriter(f.Writer)
table.SetHeader(f.Columns)
table.SetBorder(false)
table.SetAutoWrapText(false)
// Reflect over data to populate rows...
rows := toRows(data, f.Columns)
for _, row := range rows {
table.Append(row)
}
table.Render()
return "", nil
}
The JSON output is critical for scriptability. Users pipe your CLI into jq, feed it to other tools, and use it in CI pipelines. If your JSON output is inconsistent or missing fields, you will get bug reports.
Progress Indication and Streaming Output
Long-running operations need progress feedback. The github.com/schollz/progressbar library is the standard choice, but there is a subtlety: progress bars must not interfere with JSON output or pipe-friendly behavior.
func runMigration(cmd *cobra.Command, args []string) error {
migrations, err := loadMigrations(args[0])
if err != nil {
return err
}
// Only show progress bar for interactive terminals
isTerminal := term.IsTerminal(int(os.Stderr.Fd()))
var bar *progressbar.ProgressBar
if isTerminal {
bar = progressbar.NewOptions(len(migrations),
progressbar.OptionSetWriter(os.Stderr), // Progress to stderr
progressbar.OptionSetDescription("Running migrations"),
progressbar.OptionShowCount(),
progressbar.OptionClearOnFinish(),
)
}
for _, m := range migrations {
if err := m.Execute(db); err != nil {
return &CLIError{
Message: fmt.Sprintf("Migration %s failed", m.Name),
Cause: err,
}
}
if bar != nil {
bar.Add(1)
}
}
// Results go to stdout (can be piped)
return formatter.Format(MigrationResult{
Applied: len(migrations),
Status: "complete",
})
}
Key principle: progress and status go to stderr, results go to stdout. This lets users do mytool migrate ./migrations | jq .applied without progress bar characters corrupting the JSON.
Testing CLI Tools
Testing CLIs requires testing at multiple levels: unit tests for business logic, integration tests for command behavior, and end-to-end tests for the actual binary.
// Integration test: test command output without running the binary
func TestDeployCommand(t *testing.T) {
// Capture stdout
var stdout bytes.Buffer
rootCmd.SetOut(&stdout)
rootCmd.SetErr(&bytes.Buffer{})
// Set args as if called from command line
rootCmd.SetArgs([]string{"deploy", "--env", "staging", "--dry-run"})
err := rootCmd.Execute()
require.NoError(t, err)
var result DeployResult
err = json.Unmarshal(stdout.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, "staging", result.Environment)
assert.True(t, result.DryRun)
}
// End-to-end test: test the actual compiled binary
func TestBinaryE2E(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}
binary := buildBinary(t) // go build -o tempdir/mytool
defer os.Remove(binary)
cmd := exec.Command(binary, "version", "--output", "json")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "binary execution failed: %s", output)
var version VersionInfo
require.NoError(t, json.Unmarshal(output, &version))
assert.NotEmpty(t, version.Version)
assert.NotEmpty(t, version.Commit)
}
Distribution: Cross-Compilation and Release
Gos cross-compilation is a superpower. GoReleaser automates the entire release pipeline:
# .goreleaser.yaml
version: 2
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
archives:
- formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
brews:
- repository:
owner: yourorg
name: homebrew-tap
homepage: "https://github.com/yourorg/mytool"
description: "Your CLI tool description"
nfpms:
- package_name: mytool
vendor: YourOrg
homepage: "https://github.com/yourorg/mytool"
maintainer: "You "
formats:
- deb
- rpm
This generates binaries for 6 platform/architecture combinations, creates a Homebrew formula, and builds .deb and .rpm packages — all from a single goreleaser release command.
Shell Completions: The Professional Touch
Cobra generates shell completions automatically, but custom completions make your tool feel polished:
var deployCmd = &cobra.Command{
Use: "deploy [environment]",
Short: "Deploy to an environment",
// Dynamic completion for environment names
ValidArgsFunction: func(cmd *cobra.Command, args []string,
toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
envs, err := listEnvironments()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return envs, cobra.ShellCompDirectiveNoFileComp
},
RunE: runDeploy,
}
Install completions with: mytool completion bash > /etc/bash_completion.d/mytool or mytool completion zsh > "${fpath[1]}/_mytool".
Signals, Graceful Shutdown, and Context
Production CLI tools must handle interrupts gracefully. If a user presses Ctrl+C during a database migration, you need to finish the current migration step rather than leaving the database in a half-migrated state:
func runWithGracefulShutdown(fn func(ctx context.Context) error) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
errCh <- fn(ctx)
}()
select {
case err := <-errCh:
return err
case sig := <-sigCh:
fmt.Fprintf(os.Stderr,
"\nReceived %s, finishing current operation...\n", sig)
cancel()
// Wait for graceful completion with timeout
select {
case err := <-errCh:
return err
case <-time.After(30 * time.Second):
return fmt.Errorf("operation timed out during shutdown")
}
}
}
Version Information: Embed Build Metadata
Every CLI tool needs a version command that shows the build version, commit hash, and build date. Use Gos linker flags to embed this at build time:
// main.go
var (
version = "dev"
commit = "none"
date = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
info := map[string]string{
"version": version,
"commit": commit,
"built": date,
"go_version": runtime.Version(),
"os/arch": runtime.GOOS + "/" + runtime.GOARCH,
}
formatter.Format(info)
},
}
The Checklist
Before shipping a Go CLI tool, verify:
- Three-layer configuration (flags > env > config file) works correctly
- Errors include context and actionable suggestions
- JSON and text output modes work, with progress on stderr
- Shell completions are generated and documented
- Ctrl+C is handled gracefully for long-running operations
versioncommand shows build metadata- Cross-platform binaries build correctly (test on CI)
- A man page or comprehensive
--helpexists for every subcommand
Go makes building CLI tools easy. Making them production-ready requires attention to the details that tutorials skip. The tools and patterns in this article are the result of shipping real software to real users — adopt them early, and your CLI will feel professional from day one.
