Go CLI Development with Cobra & Viper
Overview
Cobra and Viper are the industry-standard libraries for building production-quality CLIs in Go. Cobra provides command structure and argument parsing, while Viper manages configuration from multiple sources with clear precedence rules.
Key Features:
-
🎯 Cobra Commands: POSIX-compliant CLI with subcommands (app verb noun --flag )
-
⚙️ Viper Config: Unified configuration from flags, env vars, and config files
-
🔄 Integration: Seamless Cobra + Viper plumbing patterns
-
🐚 Shell Completion: Auto-generated completions for bash, zsh, fish, PowerShell
-
✅ Production Ready: Battle-tested by kubectl, docker, gh, hugo
Used By: Kubernetes (kubectl), Docker CLI, GitHub CLI (gh), Hugo, Helm, and 100+ major projects
When to Use This Skill
Activate this skill when:
-
Building multi-command CLI tools with subcommands
-
Creating developer tools, project generators, or scaffolding utilities
-
Implementing admin CLIs for services or infrastructure
-
Requiring flexible configuration (flags > env vars > config files > defaults)
-
Adding shell completion for frequently-used CLIs
-
Building DevOps automation tools or deployment scripts
Cobra Framework
Command Structure Pattern
Cobra follows the APPNAME VERB NOUN --FLAG pattern popularized by git and kubectl.
// cmd/root.go package cmd
import ( "fmt" "os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{ Use: "myapp", Short: "A powerful CLI tool for developers", Long: `MyApp is a CLI tool that demonstrates best practices for building production-quality command-line applications.
Complete documentation is available at https://myapp.example.com`, }
func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }
func init() { cobra.OnInitialize(initConfig)
// Persistent flags (available to all subcommands)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.myapp.yaml)")
rootCmd.PersistentFlags().Bool("verbose", false, "verbose output")
// Bind persistent flags to viper
viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}
func initConfig() { if cfgFile != "" { viper.SetConfigFile(cfgFile) } else { home, err := os.UserHomeDir() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".myapp")
}
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
if viper.GetBool("verbose") {
fmt.Println("Using config file:", viper.ConfigFileUsed())
}
}
}
Subcommands with Arguments
// cmd/deploy.go package cmd
import ( "fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var deployCmd = &cobra.Command{
Use: "deploy [environment]",
Short: "Deploy application to specified environment",
Long: Deploy the application to the specified environment. Supports: dev, staging, production,
Args: cobra.ExactArgs(1),
ValidArgs: []string{"dev", "staging", "production"},
PreRunE: func(cmd *cobra.Command, args []string) error {
// Validation logic runs before RunE
env := args[0]
if env == "production" && !viper.GetBool("force") {
return fmt.Errorf("production deploys require --force flag")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
env := args[0]
region := viper.GetString("region")
force := viper.GetBool("force")
fmt.Printf("Deploying to %s in region %s (force=%v)\n", env, region, force)
// Actual deployment logic
return deploy(env, region, force)
},
PostRunE: func(cmd *cobra.Command, args []string) error {
// Cleanup or notifications
fmt.Println("Deployment complete")
return nil
},
}
func init() { rootCmd.AddCommand(deployCmd)
// Local flags (only for this command)
deployCmd.Flags().StringP("region", "r", "us-east-1", "AWS region")
deployCmd.Flags().BoolP("force", "f", false, "Force deployment without confirmation")
// Bind flags to viper
viper.BindPFlag("region", deployCmd.Flags().Lookup("region"))
viper.BindPFlag("force", deployCmd.Flags().Lookup("force"))
}
func deploy(env, region string, force bool) error { // Implementation return nil }
Persistent vs. Local Flags
// Persistent flags: Available to command and all subcommands rootCmd.PersistentFlags().String("config", "", "config file path") rootCmd.PersistentFlags().Bool("verbose", false, "verbose output")
// Local flags: Only available to this specific command deployCmd.Flags().String("region", "us-east-1", "deployment region") deployCmd.Flags().Bool("force", false, "force deployment")
// Required flags deployCmd.MarkFlagRequired("region")
// Flag dependencies deployCmd.MarkFlagsRequiredTogether("username", "password") deployCmd.MarkFlagsMutuallyExclusive("json", "yaml")
PreRun/PostRun Hooks
Cobra provides execution hooks for setup and cleanup:
var serverCmd = &cobra.Command{ Use: "server", Short: "Start API server",
// Execution order (all optional):
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Runs before PreRunE, inherited by subcommands
return setupLogging()
},
PreRunE: func(cmd *cobra.Command, args []string) error {
// Validation and setup before RunE
return validateConfig()
},
RunE: func(cmd *cobra.Command, args []string) error {
// Main command logic
return startServer()
},
PostRunE: func(cmd *cobra.Command, args []string) error {
// Cleanup after RunE
return cleanup()
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
// Runs after PostRunE, inherited by subcommands
return flushLogs()
},
}
Important: Use RunE , PreRunE , PostRunE (error-returning versions) instead of Run , PreRun , PostRun .
Viper Configuration Management
Configuration Priority
Viper follows a strict precedence order (highest to lowest):
-
Explicit Set (viper.Set("key", value) )
-
Command-line Flags (bound with viper.BindPFlag )
-
Environment Variables (MYAPP_KEY=value )
-
Config File (~/.myapp.yaml , ./config.yaml )
-
Key/Value Store (etcd, Consul - optional)
-
Defaults (viper.SetDefault("key", value) )
func initConfig() { // 1. Set defaults (lowest priority) viper.SetDefault("port", 8080) viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432)
// 2. Config file locations (checked in order)
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("/etc/myapp/")
viper.AddConfigPath("$HOME/.myapp")
viper.AddConfigPath(".")
// 3. Environment variables (prefix + automatic mapping)
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv() // MYAPP_PORT, MYAPP_DATABASE_HOST, etc.
// 4. Read config file (optional)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found - use defaults and env vars
} else {
// Config file found but error reading it
return err
}
}
// 5. Flags will be bound in init() functions (highest priority)
}
Environment Variable Mapping
Viper automatically maps environment variables with prefix and dot notation:
viper.SetEnvPrefix("MYAPP") // Prefix for env vars viper.AutomaticEnv() // Enable automatic mapping
// Config key → Environment variable // "port" → MYAPP_PORT // "database.host" → MYAPP_DATABASE_HOST // "database.port" → MYAPP_DATABASE_PORT // "aws.s3.region" → MYAPP_AWS_S3_REGION
Manual mapping for non-standard env var names:
viper.BindEnv("database.host", "DB_HOST") // Custom env var name viper.BindEnv("database.password", "DB_PASSWORD") // Different naming convention
Config File Formats
Viper supports multiple formats: YAML, JSON, TOML, HCL, INI, envfile, Java properties.
config.yaml:
port: 8080 log_level: info
database: host: localhost port: 5432 user: postgres ssl_mode: require
aws: region: us-east-1 s3: bucket: my-app-bucket
Accessing config values:
port := viper.GetInt("port") // 8080 dbHost := viper.GetString("database.host") // "localhost" s3Bucket := viper.GetString("aws.s3.bucket") // "my-app-bucket"
// Type-safe access if viper.IsSet("database.ssl_mode") { sslMode := viper.GetString("database.ssl_mode") }
// Unmarshal into struct
type Config struct {
Port int mapstructure:"port"
LogLevel string mapstructure:"log_level"
Database struct {
Host string mapstructure:"host"
Port int mapstructure:"port"
User string mapstructure:"user"
SSLMode string mapstructure:"ssl_mode"
} mapstructure:"database"
}
var config Config if err := viper.Unmarshal(&config); err != nil { return err }
Cobra + Viper Integration
Critical Integration Pattern
The key to Cobra + Viper integration is binding flags to Viper keys:
// cmd/root.go func init() { cobra.OnInitialize(initConfig) // Load config before command execution
// Define flags
rootCmd.PersistentFlags().String("config", "", "config file")
rootCmd.PersistentFlags().String("log-level", "info", "log level")
rootCmd.PersistentFlags().Int("port", 8080, "server port")
// Bind flags to Viper (critical step!)
viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))
viper.BindPFlag("log_level", rootCmd.PersistentFlags().Lookup("log-level"))
viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
}
func initConfig() { // This runs BEFORE command execution via cobra.OnInitialize if cfgFile := viper.GetString("config"); cfgFile != "" { viper.SetConfigFile(cfgFile) } else { viper.AddConfigPath("$HOME/.myapp") viper.AddConfigPath(".") viper.SetConfigName("config") }
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv()
viper.ReadInConfig() // Ignore errors - config file is optional
}
Flag binding strategies:
// Strategy 1: Bind each flag individually (explicit) viper.BindPFlag("log_level", rootCmd.Flags().Lookup("log-level"))
// Strategy 2: Bind all flags automatically (convenient) viper.BindPFlags(rootCmd.Flags())
// Strategy 3: Hybrid approach (recommended) // - Bind persistent flags globally // - Bind local flags in each command's init() rootCmd.PersistentFlags().String("config", "", "config file") viper.BindPFlags(rootCmd.PersistentFlags())
deployCmd.Flags().String("region", "us-east-1", "AWS region") viper.BindPFlag("deploy.region", deployCmd.Flags().Lookup("region"))
PersistentPreRun for Config Loading
Use PersistentPreRunE to load and validate configuration:
var rootCmd = &cobra.Command{ Use: "myapp", Short: "My application", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Runs before ALL commands (inherited by subcommands)
// 1. Validate required config
if !viper.IsSet("api_key") {
return fmt.Errorf("API key not configured (set MYAPP_API_KEY or add to config file)")
}
// 2. Setup logging based on config
logLevel := viper.GetString("log_level")
if err := setupLogging(logLevel); err != nil {
return fmt.Errorf("invalid log level: %w", err)
}
// 3. Initialize clients/services
apiKey := viper.GetString("api_key")
if err := initAPIClient(apiKey); err != nil {
return fmt.Errorf("failed to initialize API client: %w", err)
}
return nil
},
}
Shell Completion
Cobra generates shell completion scripts for bash, zsh, fish, and PowerShell.
Adding Completion Command
// cmd/completion.go package cmd
import ( "os"
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate shell completion script", Long: `Generate shell completion script for myapp.
To load completions:
Bash: $ source <(myapp completion bash)
To load automatically, add to ~/.bashrc:
$ echo 'source <(myapp completion bash)' >> ~/.bashrc
Zsh: $ source <(myapp completion zsh)
To load automatically, add to ~/.zshrc:
$ echo 'source <(myapp completion zsh)' >> ~/.zshrc
Fish: $ myapp completion fish | source
To load automatically:
$ myapp completion fish > ~/.config/fish/completions/myapp.fish
PowerShell: PS> myapp completion powershell | Out-String | Invoke-Expression
To load automatically, add to PowerShell profile.
`, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, Args: cobra.ExactValidArgs(1), RunE: func(cmd *cobra.Command, args []string) error { switch args[0] { case "bash": return cmd.Root().GenBashCompletion(os.Stdout) case "zsh": return cmd.Root().GenZshCompletion(os.Stdout) case "fish": return cmd.Root().GenFishCompletion(os.Stdout, true) case "powershell": return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) } return nil }, }
func init() { rootCmd.AddCommand(completionCmd) }
Custom Completion Functions
Provide dynamic completions for command arguments:
var deployCmd = &cobra.Command{ Use: "deploy [environment]", Short: "Deploy to environment", Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // Return available environments envs := []string{"dev", "staging", "production"} return envs, cobra.ShellCompDirectiveNoFileComp }, RunE: func(cmd *cobra.Command, args []string) error { return deploy(args[0]) }, }
// Custom flag completion deployCmd.RegisterFlagCompletionFunc("region", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { regions := []string{"us-east-1", "us-west-2", "eu-west-1"} return regions, cobra.ShellCompDirectiveNoFileComp })
CLI Best Practices
User-Friendly Error Messages
// ❌ BAD: Technical jargon return fmt.Errorf("db connection failed: EOF")
// ✅ GOOD: Actionable error message return fmt.Errorf("cannot connect to database at %s:%d\nPlease check:\n - Database is running\n - Credentials are correct (MYAPP_DB_PASSWORD)\n - Network connectivity", host, port)
// ✅ GOOD: Suggest remediation if !viper.IsSet("api_key") { return fmt.Errorf("API key not configured\nSet environment variable: export MYAPP_API_KEY=your-key\nOr add to config file: ~/.myapp.yaml") }
Progress Indicators
import "github.com/briandowns/spinner"
func deploy(env string) error { s := spinner.New(spinner.CharSets[11], 100*time.Millisecond) s.Suffix = " Deploying to " + env + "..." s.Start() defer s.Stop()
// Deployment logic
if err := performDeployment(env); err != nil {
s.Stop()
return err
}
s.Stop()
fmt.Println("✓ Deployment successful")
return nil
}
Output Formatting
import ( "encoding/json" "github.com/olekukonko/tablewriter" )
func displayResults(items []Item, format string) error { switch format { case "json": enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(items)
case "table":
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"ID", "Name", "Status"})
for _, item := range items {
table.Append([]string{item.ID, item.Name, item.Status})
}
table.Render()
return nil
default:
return fmt.Errorf("unknown format: %s (use json or table)", format)
}
}
Logging vs. User Output
import ( "log" "os" )
var ( // User-facing output (stdout) out = os.Stdout
// Logging and errors (stderr)
logger = log.New(os.Stderr, "[myapp] ", log.LstdFlags)
)
func RunCommand() error { // User output: stdout fmt.Fprintln(out, "Processing files...")
// Debug/verbose logging: stderr
if viper.GetBool("verbose") {
logger.Println("Reading config from", viper.ConfigFileUsed())
}
// Errors: stderr
if err := process(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return err
}
// Success message: stdout
fmt.Fprintln(out, "✓ Complete")
return nil
}
Testing CLI Applications
Testing Command Execution
// cmd/deploy_test.go package cmd
import ( "bytes" "testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func executeCommand(root *cobra.Command, args ...string) (output string, err error) { buf := new(bytes.Buffer) root.SetOut(buf) root.SetErr(buf) root.SetArgs(args)
err = root.Execute()
return buf.String(), err
}
func TestDeployCommand(t *testing.T) { tests := []struct { name string args []string wantErr bool wantOut string }{ { name: "deploy to dev", args: []string{"deploy", "dev"}, wantErr: false, wantOut: "Deploying to dev", }, { name: "deploy to production without force", args: []string{"deploy", "production"}, wantErr: true, wantOut: "production deploys require --force flag", }, { name: "deploy to production with force", args: []string{"deploy", "production", "--force"}, wantErr: false, wantOut: "Deploying to production", }, }
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, err := executeCommand(rootCmd, tt.args...)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Contains(t, output, tt.wantOut)
})
}
}
Testing with Viper Configuration
func TestCommandWithConfig(t *testing.T) { // Reset viper state before each test viper.Reset()
// Set test configuration
viper.Set("region", "eu-west-1")
viper.Set("api_key", "test-key-123")
output, err := executeCommand(rootCmd, "deploy", "staging")
require.NoError(t, err)
assert.Contains(t, output, "eu-west-1")
}
Capturing Output
func TestOutputFormat(t *testing.T) { // Capture stdout oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w defer func() { os.Stdout = oldStdout }()
// Execute command
err := listCmd.RunE(listCmd, []string{})
require.NoError(t, err)
// Read output
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
assert.Contains(t, output, "ID")
assert.Contains(t, output, "Name")
}
Decision Trees
When to Use Cobra
Use Cobra when:
-
✅ Building multi-command CLI with subcommands (e.g., git clone , docker run )
-
✅ Need POSIX-compliant flag parsing (--flag , -f )
-
✅ Want built-in help generation (--help )
-
✅ Require shell completion support
-
✅ Building professional CLI used by other developers
Don't use Cobra when:
-
❌ Simple single-command script (use flag package)
-
❌ Internal-only tool with 1-2 flags
-
❌ Prototyping or throwaway scripts
When to Use Viper
Use Viper when:
-
✅ Need configuration from multiple sources (flags, env vars, files)
-
✅ Want clear configuration precedence rules
-
✅ Support multiple config file formats (YAML, JSON, TOML)
-
✅ Require environment variable mapping with prefixes
-
✅ Need live config reloading (watch config file changes)
Don't use Viper when:
-
❌ Only using command-line flags (Cobra alone is sufficient)
-
❌ Hardcoded configuration values
-
❌ Simple scripts with no configuration
When to Add Shell Completion
Add shell completion when:
-
✅ CLI used frequently by developers (daily/hourly)
-
✅ Many subcommands or complex flag combinations
-
✅ Arguments have known valid values (e.g., environments, regions)
-
✅ Building professional developer tools
Skip shell completion when:
-
❌ CLI used rarely (monthly or less)
-
❌ Simple commands with few options
-
❌ Internal-only tools
When to Use Persistent Flags
Use persistent flags when:
-
✅ Flag applies to ALL subcommands (e.g., --verbose , --config )
-
✅ Common configuration shared across commands
-
✅ Global behavior modifiers (e.g., --dry-run , --output )
Use local flags when:
-
✅ Flag only relevant to specific command
-
✅ Command-specific parameters (e.g., --region for deploy command)
Anti-Patterns
❌ Not Handling Errors in PreRunE/RunE
Wrong:
var deployCmd = &cobra.Command{ Use: "deploy", Run: func(cmd *cobra.Command, args []string) { deploy(args[0]) // Ignores error! }, }
Correct:
var deployCmd = &cobra.Command{ Use: "deploy", RunE: func(cmd *cobra.Command, args []string) error { return deploy(args[0]) // Proper error handling }, }
❌ Mixing Configuration Sources Without Clear Precedence
Wrong:
// Confusing: Which takes precedence? config.Port = viper.GetInt("port") if os.Getenv("PORT") != "" { config.Port = atoi(os.Getenv("PORT")) } if *flagPort != 0 { config.Port = *flagPort }
Correct:
// Clear: Viper handles precedence automatically viper.BindPFlag("port", rootCmd.Flags().Lookup("port")) viper.SetEnvPrefix("MYAPP") viper.AutomaticEnv() viper.SetDefault("port", 8080)
config.Port = viper.GetInt("port") // Respects: flag > env > config > default
❌ Forgetting to Bind Flags to Viper
Wrong:
rootCmd.Flags().String("region", "us-east-1", "AWS region") // Flag not bound to Viper - won't respect precedence!
func deploy() { region := viper.GetString("region") // Always returns config file value }
Correct:
rootCmd.Flags().String("region", "us-east-1", "AWS region") viper.BindPFlag("region", rootCmd.Flags().Lookup("region")) // Bind it!
func deploy() { region := viper.GetString("region") // Respects flag > env > config }
❌ Not Testing CLI Commands
Wrong:
// No tests for CLI commands - bugs slip through
Correct:
func TestDeployCommand(t *testing.T) { output, err := executeCommand(rootCmd, "deploy", "staging", "--region", "eu-west-1") require.NoError(t, err) assert.Contains(t, output, "Deploying to staging") assert.Contains(t, output, "eu-west-1") }
❌ Poor Error Messages
Wrong:
return fmt.Errorf("connection failed") // Unhelpful
Correct:
return fmt.Errorf("cannot connect to database at %s:%d\nCheck:\n - Database is running\n - Credentials (MYAPP_DB_PASSWORD)\n - Firewall rules", host, port)
❌ Using Run Instead of RunE
Wrong:
var rootCmd = &cobra.Command{ Use: "myapp", Run: func(cmd *cobra.Command, args []string) { if err := execute(); err != nil { fmt.Println(err) // Error not propagated } }, }
Correct:
var rootCmd = &cobra.Command{ Use: "myapp", RunE: func(cmd *cobra.Command, args []string) error { return execute() // Cobra handles error display and exit code }, }
Production Example
Minimal production-ready CLI structure:
myapp/ ├── cmd/ │ ├── root.go # Root command + config loading │ ├── deploy.go # Deploy subcommand │ ├── status.go # Status subcommand │ └── completion.go # Shell completion ├── main.go # Entry point ├── config.yaml # Example config file └── go.mod
main.go:
package main
import "myapp/cmd"
func main() { cmd.Execute() }
cmd/root.go: See "Command Structure Pattern" section above
Building and installing:
Development
go run main.go deploy staging --region us-west-2
Production build
go build -o myapp
Install globally
go install
Enable shell completion
myapp completion bash > /etc/bash_completion.d/myapp
Resources
Official Documentation:
-
Cobra User Guide - Official framework documentation
-
Viper Documentation - Configuration management guide
Learning Resources:
-
"Building CLI Apps in Go with Cobra & Viper" (November 2025) - Comprehensive tutorial
-
"The Cobra & Viper Journey" - Learning path for CLI development
-
Cobra Generator - Scaffolding tool for new CLIs
Production Examples:
-
kubectl - Kubernetes CLI
-
docker - Docker CLI
-
gh - GitHub CLI
-
hugo - Static site generator
Related Skills:
-
golang-testing-strategies
-
Testing CLI commands comprehensively
-
golang-http-servers
-
Building API servers with configuration
-
golang-concurrency-patterns
-
Async operations in CLI tools
Success Criteria
You know you've mastered Cobra + Viper when:
-
✅ Commands follow POSIX conventions (VERB NOUN --FLAG )
-
✅ Configuration precedence is clear: flags > env > config > defaults
-
✅ All flags bound to Viper for unified config access
-
✅ Shell completion generated for all major shells
-
✅ Error messages are actionable and user-friendly
-
✅ CLI commands have comprehensive tests
-
✅ Help text auto-generated and accurate
-
✅ PersistentPreRunE used for global setup/validation
-
✅ Separation of concerns: user output (stdout) vs. logging (stderr)
-
✅ Config files optional - CLI works with flags/env vars alone