Building a CLI in Go: a practical guide for developers
Most teams eventually need internal tooling: deploy scripts, database migrations, data processing pipelines. Writing these as CLIs in Go gives you fast, statically-typed binaries that run anywhere without runtime dependencies. Building a CLI in Go: a practical guide for developers walks through the real patterns we use in production to make command-line tools that your team will actually want to use.
Why Go for CLI tooling?
Go compiles to a single binary with no external dependencies. Ship it to staging, production, or hand it to a teammate—it just works. Compare that to Node scripts that need npm, Python scripts that need virtual environments, or shell scripts that break across operating systems. For our teams shipping CLIs across e-commerce platforms, trading systems, and SaaS applications, Go eliminates friction.
A well-built CLI also feels professional. Proper command structure, help text, flag validation, and consistent output earn trust. Users learn your CLI's patterns once and apply them everywhere.
Cobra: Commands, flags and help text
Cobra is the standard choice for building structured CLI applications in Go. It handles command hierarchies, flags (both persistent and per-command), and auto-generated help documentation. Rather than parsing raw os.Args, you describe what your CLI does, and Cobra handles the rest.
Start with a root command and add subcommands as needed. Here's a practical example for a deployment tool:
package main
import (
"fmt"
"log"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "deploy",
Short: "Deployment and infrastructure management",
Long: "A CLI for managing deployments across environments",
}
var deployCmd = &cobra.Command{
Use: "service [service-name]",
Short: "Deploy a service",
Long: "Deploy a service to staging or production",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
env, _ := cmd.Flags().GetString("env")
version, _ := cmd.Flags().GetString("version")
fmt.Printf("Deploying %s to %s (version: %s)\n", args[0], env, version)
return nil
},
}
func init() {
rootCmd.AddCommand(deployCmd)
// Per-command flags
deployCmd.Flags().StringP("env", "e", "staging", "Target environment")
deployCmd.Flags().StringP("version", "v", "", "Service version (required)")
deployCmd.MarkFlagRequired("version")
// Persistent flags (available to all commands)
rootCmd.PersistentFlags().StringP("config", "c", "", "Config file path")
}
func main() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}Notice how RunE returns an error. Cobra catches it and exits with code 1, keeping error handling consistent. Short flags (-e) and long flags (--env) both work automatically.
Flags have sensible defaults. Your users run deploy service api --version 1.2.3 or deploy service api -e prod -v 1.2.3—both work. Cobra generates deploy --help and deploy service --help automatically from your command descriptions.
For complex CLIs, organize your code into separate packages. Put each command's logic in its own file under a cmd/ directory. As your tool grows, this structure scales.
Config: Viper or env vars for settings
CLIs need settings: API endpoints, credentials, defaults. Hard-coding them is inflexible; reading from environment every time is repetitive. Viper handles both cleanly.
Viper reads config from files (YAML, TOML, JSON), environment variables, or flags, with a clear precedence order. It's invaluable when you need different settings across development, staging, and production.
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var deployCmd = &cobra.Command{
Use: "service [service-name]",
Short: "Deploy a service",
RunE: func(cmd *cobra.Command, args []string) error {
// Load config from flag, env, or config file
configPath, _ := cmd.Flags().GetString("config")
if configPath != "" {
viper.SetConfigFile(configPath)
if err := viper.ReadInConfig(); err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
}
// Environment variables override config file
viper.SetEnvPrefix("DEPLOY")
viper.AutomaticEnv()
// Retrieve settings (env vars take precedence)
apiEndpoint := viper.GetString("api_endpoint")
if apiEndpoint == "" {
apiEndpoint = "https://api.example.com"
}
fmt.Printf("Using API endpoint: %s\n", apiEndpoint)
return nil
},
}
func init() {
rootCmd.AddCommand(deployCmd)
deployCmd.Flags().StringP("config", "c", "", "Config file path")
}With this setup, your users can:
- Use defaults:
deploy service api --version 1.2.3 - Override with env:
DEPLOY_API_ENDPOINT=https://staging-api.example.com deploy service api --version 1.2.3 - Use a config file:
deploy --config config.yaml service api --version 1.2.3
For sensitive data (API keys, passwords), always use environment variables, never config files. Many teams source these from secret management systems before invoking the CLI.
Output: Readable tables and JSON output modes
Good CLIs adapt their output to the user's needs. Interactive output (tables, colored text) is great for direct invocation. JSON output is essential when another tool or script needs to parse the result.
Here's a practical pattern for supporting both:
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/olekukonko/tablewriter"
"github.com/spf13/cobra"
)
type Deployment struct {
Service string
Version string
Env string
Status string
Timestamp string
}
var listCmd = &cobra.Command{
Use: "list",
Short: "List recent deployments",
RunE: func(cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("format")
// Fetch deployments from API
deployments := []Deployment{
{"api", "1.2.3", "prod", "success", "2024-01-15 10:30"},
{"web", "2.1.0", "prod", "success", "2024-01-15 10:15"},
{"worker", "0.5.1", "staging", "pending", "2024-01-15 10:05"},
}
if format == "json" {
return outputJSON(deployments)
}
return outputTable(deployments)
},
}
func outputTable(deployments []Deployment) error {
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Service", "Version", "Environment", "Status", "Timestamp"})
table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true})
for _, d := range deployments {
table.Append([]string{d.Service, d.Version, d.Env, d.Status, d.Timestamp})
}
table.Render()
return nil
}
func outputJSON(deployments []Deployment) error {
data, err := json.MarshalIndent(deployments, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
func init() {
rootCmd.AddCommand(listCmd)
listCmd.Flags().StringP("format", "f", "table", "Output format: table or json")
}Your team runs deploy list and sees a clean table. A CI/CD pipeline runs deploy list --format json and pipes it to jq for filtering. One tool, two output modes.
Many production CLIs also support CSV output or custom templates via Go's text/template package. Choose based on what your users actually need.
Distribute: Single binaries with GoReleaser and Homebrew taps
You've built a great CLI. Now distribute it so your team can actually use it. GoReleaser automates building binaries for every platform (Linux, macOS, Windows), creating checksums, and publishing to GitHub Releases. Then set up a Homebrew tap so macOS users install it with brew install your-org/tools/deploy.
Create a .goreleaser.yaml file in your repository:
version: 2
builds:
- id: deploy
main: ./main.go
binary: deploy
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
brews:
- tap:
owner: your-org
name: homebrew-tools
token: "{{ .Env.GITHUB_TOKEN }}"
commit_author:
name: ci-user
email: ci@example.com
install: |
bin.install "deploy"In your CI/CD (GitHub Actions, GitLab CI), tag a release and run goreleaser release. It builds for all platforms, uploads artifacts to GitHub Releases, and updates your Homebrew tap automatically.
Your team then installs with:
brew tap your-org/tools
brew install deployUpdates are trivial: brew upgrade deploy. This is how professional tools work. Your internal CLI feels like a real product.
For non-macOS users, distribute via GitHub Releases directly, or publish to artifact repositories your organization already uses (Artifactory, Cloud Storage, etc.).
Common mistakes
- Ignoring error handling: Use
RunEinstead ofRunso errors exit cleanly with non-zero codes. CI pipelines depend on this. - Unclear flag names: Avoid abbreviations that only make sense to you.
--wait-timeoutbeats--wt. Your future self will thank you. - Hard-coded credentials: Never. Always read from environment or secure vaults. A leaked binary is a leaked credential.
- No version information: Embed a version at build time using
-ldflags "-X main.Version=1.0.0". Users need to know what they're running. - Inconsistent exit codes: Use 0 for success, 1 for general errors, 2 for usage errors. Scripts rely on this.
- Verbose output by default: Keep output clean. Add a
--verboseflag for detailed logging, not the reverse.
Key takeaways
- Use Cobra for command structure, flags, and help generation—it scales as your CLI grows.
- Leverage Viper for configuration: it elegantly handles files, env vars, and defaults with sensible precedence.
- Support both human-readable and machine-parseable output; add
--format jsonor--format csvfor scripting. - Automate distribution with GoReleaser and Homebrew taps; a CLI people can't easily install won't be used.
- Treat your CLI like a product: clear commands, helpful error messages, and consistent behavior earn your team's trust.






Comments
Loading comments…